diff --git a/.env.example b/.env.example
index fb0f7308d..c61ceba1e 100644
--- a/.env.example
+++ b/.env.example
@@ -16,6 +16,11 @@ DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
+# Specify when the site is served from a port that is not the default
+# for the protocol (80 for HTTP or 443 for HTTPS).
+# Probably only necessary in development.
+# PORT=1333
+
MEDIA_ROOT=images/
# Database configuration
@@ -71,14 +76,20 @@ ENABLE_THUMBNAIL_GENERATION=true
USE_S3=false
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
+# seconds for signed S3 urls to expire
+# this is currently only used for user export files
+S3_SIGNED_URL_EXPIRY=900
# Commented are example values if you use a non-AWS, S3-compatible service
# AWS S3 should work with only AWS_STORAGE_BUCKET_NAME and AWS_S3_REGION_NAME
# non-AWS S3-compatible services will need AWS_STORAGE_BUCKET_NAME,
-# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL
+# along with both AWS_S3_CUSTOM_DOMAIN and AWS_S3_ENDPOINT_URL.
+# AWS_S3_URL_PROTOCOL must end in ":" and defaults to the same protocol as
+# the BookWyrm instance ("http:" or "https:", based on USE_SSL).
# AWS_STORAGE_BUCKET_NAME= # "example-bucket-name"
# AWS_S3_CUSTOM_DOMAIN=None # "example-bucket-name.s3.fr-par.scw.cloud"
+# AWS_S3_URL_PROTOCOL=None # "http:"
# AWS_S3_REGION_NAME=None # "fr-par"
# AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud"
@@ -133,7 +144,14 @@ HTTP_X_FORWARDED_PROTO=false
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.
+# Additional hosts to allow in the Content-Security-Policy, "self" (should be
+# DOMAIN with optionally ":" + PORT) and AWS_S3_CUSTOM_DOMAIN (if used) are
+# added by default. Value should be a comma-separated list of host names.
CSP_ADDITIONAL_HOSTS=
+
+# Time before being logged out (in seconds)
+# SESSION_COOKIE_AGE=2592000 # current default: 30 days
+
+# Maximum allowed memory for file uploads (increase if users are having trouble
+# uploading BookWyrm export files).
+# DATA_UPLOAD_MAX_MEMORY_MiB=100
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..570174248
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,68 @@
+
+## Description
+
+
+
+
+
+- Related Issue #
+- Closes #
+
+## What type of Pull Request is this?
+
+
+- [ ] Bug Fix
+- [ ] Enhancement
+- [ ] Plumbing / Internals / Dependencies
+- [ ] Refactor
+
+## Does this PR change settings or dependencies, or break something?
+
+
+- [ ] This PR changes or adds default settings, configuration, or .env values
+- [ ] This PR changes or adds dependencies
+- [ ] This PR introduces other breaking changes
+
+### Details of breaking or configuration changes (if any of above checked)
+
+
+## Documentation
+
+
+
+
+- [ ] New or amended documentation will be required if this PR is merged
+- [ ] I have created a matching pull request in the Documentation repository
+- [ ] I intend to create a matching pull request in the Documentation repository after this PR is merged
+
+
+
+### Tests
+
+
+- [ ] My changes do not need new tests
+- [ ] All tests I have added are passing
+- [ ] I have written tests but need help to make them pass
+- [ ] I have not written tests and need help to write them
diff --git a/.github/release.yml b/.github/release.yml
new file mode 100644
index 000000000..3a347bf51
--- /dev/null
+++ b/.github/release.yml
@@ -0,0 +1,26 @@
+changelog:
+ exclude:
+ labels:
+ - ignore-for-release
+ categories:
+ - title: ‼️ Breaking Changes & New Settings ⚙️
+ labels:
+ - breaking-change
+ - config-change
+ - title: Updated Dependencies 🧸
+ labels:
+ - dependencies
+ - title: New Features 🎉
+ labels:
+ - enhancement
+ - title: Bug Fixes 🐛
+ labels:
+ - fix
+ - bug
+ - title: Internals/Plumbing 👩🔧
+ - plumbing
+ - tests
+ - deployment
+ - title: Other Changes
+ labels:
+ - "*"
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
deleted file mode 100644
index 4e7be4af3..000000000
--- a/.github/workflows/black.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-name: Python Formatting (run ./bw-dev black to fix)
-
-on:
- push:
- branches: [ main ]
- pull_request:
- branches: [ main ]
-
-jobs:
- lint:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v3
- - uses: actions/setup-python@v4
- - uses: psf/black@22.12.0
- with:
- version: 22.12.0
diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml
index 68bb05d7e..014745a52 100644
--- a/.github/workflows/codeql-analysis.yml
+++ b/.github/workflows/codeql-analysis.yml
@@ -36,11 +36,11 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v3
+ uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@v2
+ uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -51,7 +51,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
- uses: github/codeql-action/autobuild@v2
+ uses: github/codeql-action/autobuild@v3
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -65,4 +65,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v2
+ uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/curlylint.yaml b/.github/workflows/curlylint.yaml
index 8d5c6b4f7..10ad04ce1 100644
--- a/.github/workflows/curlylint.yaml
+++ b/.github/workflows/curlylint.yaml
@@ -10,7 +10,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Install curlylint
run: pip install curlylint
diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml
deleted file mode 100644
index da11fe09e..000000000
--- a/.github/workflows/django-tests.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-name: Run Python Tests
-on:
- push:
- branches: [ main ]
- pull_request:
- branches: [ main ]
-
-jobs:
- build:
-
- runs-on: ubuntu-20.04
- services:
- postgres:
- image: postgres:13
- env:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: hunter2
- options: >-
- --health-cmd pg_isready
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
- ports:
- - 5432:5432
- steps:
- - uses: actions/checkout@v3
- - name: Set up Python
- 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: Run Tests
- 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: |
- pytest -n 3
diff --git a/.github/workflows/lint-frontend.yaml b/.github/workflows/lint-frontend.yaml
index 0d0559e40..b0322f371 100644
--- a/.github/workflows/lint-frontend.yaml
+++ b/.github/workflows/lint-frontend.yaml
@@ -19,10 +19,11 @@ jobs:
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Install modules
- run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
+ # run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
+ run: npm install eslint@^8.9.0
# See .stylelintignore for files that are not linted.
# - name: Run stylelint
diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml
deleted file mode 100644
index 1a641edd2..000000000
--- a/.github/workflows/mypy.yml
+++ /dev/null
@@ -1,50 +0,0 @@
-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
diff --git a/.github/workflows/prettier.yaml b/.github/workflows/prettier.yaml
index 501516ae1..9c05c7476 100644
--- a/.github/workflows/prettier.yaml
+++ b/.github/workflows/prettier.yaml
@@ -14,7 +14,7 @@ jobs:
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- name: Install modules
run: npm install prettier@2.5.1
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
deleted file mode 100644
index 3811c97d3..000000000
--- a/.github/workflows/pylint.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: Pylint
-
-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 pylint
- run: |
- pylint bookwyrm/
-
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
new file mode 100644
index 000000000..68e3b7b65
--- /dev/null
+++ b/.github/workflows/python.yml
@@ -0,0 +1,99 @@
+name: Python
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+# overrides for .env.example
+env:
+ POSTGRES_HOST: 127.0.0.1
+ PGPORT: 5432
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: hunter2
+ POSTGRES_DB: github_actions
+ SECRET_KEY: beepbeep
+ EMAIL_HOST_USER: ""
+ EMAIL_HOST_PASSWORD: ""
+
+jobs:
+ pytest:
+ name: Tests (pytest)
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:13
+ env: # does not inherit from jobs.build.env
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: hunter2
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+ cache: pip
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ pip install pytest-github-actions-annotate-failures
+ - name: Set up .env
+ run: cp .env.example .env
+ - name: Check migrations up-to-date
+ run: python ./manage.py makemigrations --check -v 3
+ - name: Run Tests
+ run: pytest -n 3
+
+ pylint:
+ name: Linting (pylint)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+ cache: pip
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ - name: Analyse code with pylint
+ run: pylint bookwyrm/
+
+ mypy:
+ name: Typing (mypy)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Python 3.11
+ uses: actions/setup-python@v5
+ with:
+ python-version: 3.11
+ cache: pip
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ - name: Set up .env
+ run: cp .env.example .env
+ - name: Analyse code with mypy
+ run: mypy bookwyrm celerywyrm
+
+ black:
+ name: Formatting (black; run ./bw-dev black to fix)
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ - uses: psf/black@stable
+ with:
+ version: "22.*"
diff --git a/.gitignore b/.gitignore
index ec2a08f80..fd6cc7547 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,8 @@
# BookWyrm
.env
/images/
+/exports/
+/static/
bookwyrm/static/css/bookwyrm.css
bookwyrm/static/css/themes/
!bookwyrm/static/css/themes/bookwyrm-*.scss
@@ -36,3 +38,6 @@ nginx/default.conf
#macOS
**/.DS_Store
+
+# Docker
+docker-compose.override.yml
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 000000000..ed29060e6
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1 @@
+'trailingComma': 'es5'
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
index 464638853..e89f7d536 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -3,7 +3,19 @@ ignore=migrations
load-plugins=pylint.extensions.no_self_use
[MESSAGES CONTROL]
-disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001,import-error
+disable =
+ cyclic-import,
+ duplicate-code,
+ fixme,
+ no-member,
+ raise-missing-from,
+ too-few-public-methods,
+ too-many-ancestors,
+ too-many-instance-attributes,
+ unnecessary-lambda-assignment,
+ unsubscriptable-object,
+enable =
+ useless-suppression
[FORMAT]
max-line-length=88
diff --git a/Dockerfile b/Dockerfile
index b3cd26e88..82b0c92c5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.9
+FROM python:3.11
ENV PYTHONUNBUFFERED 1
diff --git a/FEDERATION.md b/FEDERATION.md
index dd0c917e2..d80e98bd3 100644
--- a/FEDERATION.md
+++ b/FEDERATION.md
@@ -13,14 +13,15 @@ User relationship interactions follow the standard ActivityPub spec.
- `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`
+- `Undo`: reverses a `Block` or `Follow`
### 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`
+- `Undo/*`,: Reverses an `Announce`, `Like`, or `Move`
+- `Move/User`: Moves a user from one ActivityPub id to another.
### Collections
User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection)
diff --git a/README.md b/README.md
index f8b2eb1f6..7e27d44e6 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,6 @@ BookWyrm is a social network for tracking your reading, talking about books, wri
## Links
[![Mastodon Follow](https://img.shields.io/mastodon/follow/000146121?domain=https%3A%2F%2Ftech.lgbt&style=social)](https://tech.lgbt/@bookwyrm)
-[![Twitter Follow](https://img.shields.io/twitter/follow/BookWyrmSocial?style=social)](https://twitter.com/BookWyrmSocial)
- [Project homepage](https://joinbookwyrm.com/)
- [Support](https://patreon.com/bookwyrm)
diff --git a/VERSION b/VERSION
new file mode 100644
index 000000000..f38fc5393
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.7.3
diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py
index 05ca44476..41decd68a 100644
--- a/bookwyrm/activitypub/__init__.py
+++ b/bookwyrm/activitypub/__init__.py
@@ -4,7 +4,11 @@ import sys
from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention, Hashtag
-from .base_activity import ActivitySerializerError, resolve_remote_id
+from .base_activity import (
+ ActivitySerializerError,
+ resolve_remote_id,
+ get_representative,
+)
from .image import Document, Image
from .note import Note, GeneratedNote, Article, Comment, Quotation
from .note import Review, Rating
@@ -19,6 +23,7 @@ from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block
from .verbs import Add, Remove
from .verbs import Announce, Like
+from .verbs import Move
# this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index c78b4f195..dc4b8f6ae 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -1,7 +1,10 @@
""" basics for an activitypub serializer """
+from __future__ import annotations
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
import logging
+from typing import Optional, Union, TypeVar, overload, Any
+
import requests
from django.apps import apps
@@ -10,12 +13,16 @@ from django.utils.http import http_date
from bookwyrm import models
from bookwyrm.connectors import ConnectorException, get_data
+from bookwyrm.models import base_model
from bookwyrm.signatures import make_signature
from bookwyrm.settings import DOMAIN, INSTANCE_ACTOR_USERNAME
from bookwyrm.tasks import app, MISC
logger = logging.getLogger(__name__)
+# pylint: disable=invalid-name
+TBookWyrmModel = TypeVar("TBookWyrmModel", bound=base_model.BookWyrmModel)
+
class ActivitySerializerError(ValueError):
"""routine problems serializing activitypub json"""
@@ -65,7 +72,13 @@ class ActivityObject:
id: str
type: str
- def __init__(self, activity_objects=None, **kwargs):
+ def __init__(
+ self,
+ activity_objects: Optional[
+ dict[str, Union[str, list[str], ActivityObject, base_model.BookWyrmModel]]
+ ] = None,
+ **kwargs: Any,
+ ):
"""this lets you pass in an object with fields that aren't in the
dataclass, which it ignores. Any field in the dataclass is required or
has a default value"""
@@ -101,13 +114,13 @@ 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,
- allow_external_connections=True,
- ):
+ model: Optional[type[TBookWyrmModel]] = None,
+ instance: Optional[TBookWyrmModel] = None,
+ allow_create: bool = True,
+ save: bool = True,
+ overwrite: bool = True,
+ allow_external_connections: bool = True,
+ ) -> Optional[TBookWyrmModel]:
"""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)
@@ -224,7 +237,7 @@ class ActivityObject:
omit = kwargs.get("omit", ())
data = self.__dict__.copy()
# recursively serialize
- for (k, v) in data.items():
+ for k, v in data.items():
try:
if issubclass(type(v), ActivityObject):
data[k] = v.serialize()
@@ -237,7 +250,10 @@ class ActivityObject:
pass
data = {k: v for (k, v) in data.items() if v is not None and k not in omit}
if "@context" not in omit:
- data["@context"] = "https://www.w3.org/ns/activitystreams"
+ data["@context"] = [
+ "https://www.w3.org/ns/activitystreams",
+ {"Hashtag": "as:Hashtag"},
+ ]
return data
@@ -296,14 +312,40 @@ def get_model_from_type(activity_type):
# pylint: disable=too-many-arguments
+@overload
def resolve_remote_id(
- remote_id,
- model=None,
- refresh=False,
- save=True,
- get_activity=False,
- allow_external_connections=True,
-):
+ remote_id: str,
+ model: type[TBookWyrmModel],
+ refresh: bool = False,
+ save: bool = True,
+ get_activity: bool = False,
+ allow_external_connections: bool = True,
+) -> TBookWyrmModel:
+ ...
+
+
+# pylint: disable=too-many-arguments
+@overload
+def resolve_remote_id(
+ remote_id: str,
+ model: Optional[str] = None,
+ refresh: bool = False,
+ save: bool = True,
+ get_activity: bool = False,
+ allow_external_connections: bool = True,
+) -> base_model.BookWyrmModel:
+ ...
+
+
+# pylint: disable=too-many-arguments
+def resolve_remote_id(
+ remote_id: str,
+ model: Optional[Union[str, type[base_model.BookWyrmModel]]] = None,
+ refresh: bool = False,
+ save: bool = True,
+ get_activity: bool = False,
+ allow_external_connections: bool = True,
+) -> base_model.BookWyrmModel:
"""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
@@ -358,19 +400,15 @@ def resolve_remote_id(
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
+ to sign outgoing HTTP GET requests"""
+ return models.User.objects.get_or_create(
+ username=f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}",
+ defaults={
+ "email": "bookwyrm@localhost",
+ "local": True,
+ "localname": INSTANCE_ACTOR_USERNAME,
+ },
+ )[0]
def get_activitypub_data(url):
@@ -389,6 +427,7 @@ def get_activitypub_data(url):
"Date": now,
"Signature": make_signature("get", sender, url, now),
},
+ timeout=15,
)
except requests.RequestException:
raise ConnectorException()
diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py
index d3aca4471..9a268f905 100644
--- a/bookwyrm/activitypub/book.py
+++ b/bookwyrm/activitypub/book.py
@@ -1,6 +1,6 @@
""" book and author data """
from dataclasses import dataclass, field
-from typing import List
+from typing import Optional
from .base_activity import ActivityObject
from .image import Document
@@ -11,19 +11,17 @@ from .image import Document
class BookData(ActivityObject):
"""shared fields for all book data and authors"""
- openlibraryKey: str = None
- inventaireId: str = None
- librarythingKey: str = None
- goodreadsKey: str = None
- bnfId: str = None
- viaf: str = None
- wikidata: str = None
- asin: str = None
- aasin: str = None
- isfdb: str = None
- lastEditedBy: str = None
- links: List[str] = field(default_factory=lambda: [])
- fileLinks: List[str] = field(default_factory=lambda: [])
+ openlibraryKey: Optional[str] = None
+ inventaireId: Optional[str] = None
+ librarythingKey: Optional[str] = None
+ goodreadsKey: Optional[str] = None
+ bnfId: Optional[str] = None
+ viaf: Optional[str] = None
+ wikidata: Optional[str] = None
+ asin: Optional[str] = None
+ aasin: Optional[str] = None
+ isfdb: Optional[str] = None
+ lastEditedBy: Optional[str] = None
# pylint: disable=invalid-name
@@ -35,17 +33,19 @@ class Book(BookData):
sortTitle: str = None
subtitle: str = None
description: str = ""
- languages: List[str] = field(default_factory=lambda: [])
+ languages: list[str] = field(default_factory=list)
series: str = ""
seriesNumber: str = ""
- subjects: List[str] = field(default_factory=lambda: [])
- subjectPlaces: List[str] = field(default_factory=lambda: [])
+ subjects: list[str] = field(default_factory=list)
+ subjectPlaces: list[str] = field(default_factory=list)
- authors: List[str] = field(default_factory=lambda: [])
+ authors: list[str] = field(default_factory=list)
firstPublishedDate: str = ""
publishedDate: str = ""
- cover: Document = None
+ fileLinks: list[str] = field(default_factory=list)
+
+ cover: Optional[Document] = None
type: str = "Book"
@@ -58,22 +58,21 @@ class Edition(Book):
isbn10: str = ""
isbn13: str = ""
oclcNumber: str = ""
- pages: int = None
+ pages: Optional[int] = None
physicalFormat: str = ""
physicalFormatDetail: str = ""
- publishers: List[str] = field(default_factory=lambda: [])
+ publishers: list[str] = field(default_factory=list)
editionRank: int = 0
type: str = "Edition"
-# pylint: disable=invalid-name
@dataclass(init=False)
class Work(Book):
"""work instance of a book object"""
lccn: str = ""
- editions: List[str] = field(default_factory=lambda: [])
+ editions: list[str] = field(default_factory=list)
type: str = "Work"
@@ -83,12 +82,12 @@ class Author(BookData):
"""author of a book"""
name: str
- isni: str = None
- viafId: str = None
- gutenbergId: str = None
- born: str = None
- died: str = None
- aliases: List[str] = field(default_factory=lambda: [])
+ isni: Optional[str] = None
+ viafId: Optional[str] = None
+ gutenbergId: Optional[str] = None
+ born: Optional[str] = None
+ died: Optional[str] = None
+ aliases: list[str] = field(default_factory=list)
bio: str = ""
wikipediaLink: str = ""
type: str = "Author"
diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py
index a3da6d24c..7d811331c 100644
--- a/bookwyrm/activitypub/ordered_collection.py
+++ b/bookwyrm/activitypub/ordered_collection.py
@@ -18,7 +18,6 @@ class OrderedCollection(ActivityObject):
type: str = "OrderedCollection"
-# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
"""an ordered collection with privacy settings"""
diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py
index 61c15a579..dfec92e4c 100644
--- a/bookwyrm/activitypub/person.py
+++ b/bookwyrm/activitypub/person.py
@@ -1,5 +1,5 @@
""" actor serializer """
-from dataclasses import dataclass, field
+from dataclasses import dataclass
from typing import Dict
from .base_activity import ActivityObject
@@ -35,9 +35,11 @@ class Person(ActivityObject):
endpoints: Dict = None
name: str = None
summary: str = None
- icon: Image = field(default_factory=lambda: {})
+ icon: Image = None
bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False
discoverable: str = False
hideFollows: str = False
+ movedTo: str = None
+ alsoKnownAs: dict[str] = None
type: str = "Person"
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index 4b7514b5a..549f14c9c 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -22,7 +22,6 @@ class Verb(ActivityObject):
self.object.to_model(allow_external_connections=allow_external_connections)
-# pylint: disable=invalid-name
@dataclass(init=False)
class Create(Verb):
"""Create activity"""
@@ -33,7 +32,6 @@ class Create(Verb):
type: str = "Create"
-# pylint: disable=invalid-name
@dataclass(init=False)
class Delete(Verb):
"""Create activity"""
@@ -63,7 +61,6 @@ class Delete(Verb):
# if we can't find it, we don't need to delete it because we don't have it
-# pylint: disable=invalid-name
@dataclass(init=False)
class Update(Verb):
"""Update activity"""
@@ -171,9 +168,19 @@ class Reject(Verb):
type: str = "Reject"
def action(self, allow_external_connections=True):
- """reject a follow request"""
- obj = self.object.to_model(save=False, allow_create=False)
- obj.reject()
+ """reject a follow or follow request"""
+
+ for model_name in ["UserFollowRequest", "UserFollows", None]:
+ model = apps.get_model(f"bookwyrm.{model_name}") if model_name else None
+ if obj := self.object.to_model(
+ model=model,
+ save=False,
+ allow_create=False,
+ allow_external_connections=allow_external_connections,
+ ):
+ # Reject the first model that can be built.
+ obj.reject()
+ break
@dataclass(init=False)
@@ -217,7 +224,6 @@ class Like(Verb):
self.to_model(allow_external_connections=allow_external_connections)
-# pylint: disable=invalid-name
@dataclass(init=False)
class Announce(Verb):
"""boosting a status"""
@@ -231,3 +237,30 @@ class Announce(Verb):
def action(self, allow_external_connections=True):
"""boost"""
self.to_model(allow_external_connections=allow_external_connections)
+
+
+@dataclass(init=False)
+class Move(Verb):
+ """a user moving an object"""
+
+ object: str
+ type: str = "Move"
+ origin: str = None
+ target: str = None
+
+ def action(self, allow_external_connections=True):
+ """move"""
+
+ object_is_user = resolve_remote_id(remote_id=self.object, model="User")
+
+ if object_is_user:
+ model = apps.get_model("bookwyrm.MoveUser")
+
+ self.to_model(
+ model=model,
+ save=True,
+ allow_external_connections=allow_external_connections,
+ )
+ else:
+ # we might do something with this to move other objects at some point
+ pass
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index 3e1432a4b..08fb757d5 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -32,7 +32,7 @@ class ActivityStream(RedisStore):
stream_id = self.stream_id(user_id)
return f"{stream_id}-unread-by-type"
- def get_rank(self, obj): # pylint: disable=no-self-use
+ def get_rank(self, obj):
"""statuses are sorted by date published"""
return obj.published_date.timestamp()
@@ -112,7 +112,7 @@ class ActivityStream(RedisStore):
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,
+ status.reply_parent.privacy if status.reply_parent else status.privacy,
)
# direct messages don't appear in feeds, direct comments/reviews/etc do
if status.privacy == "direct" and status.status_type == "Note":
@@ -139,14 +139,14 @@ class ActivityStream(RedisStore):
| (
Q(following=status.user) & Q(following=status.reply_parent.user)
) # if the user is following both authors
- ).distinct()
+ )
# only visible to the poster's followers and tagged users
elif status.privacy == "followers":
audience = audience.filter(
Q(following=status.user) # if the user is following the author
)
- return audience.distinct()
+ return audience.distinct("id")
@tracer.start_as_current_span("ActivityStream.get_audience")
def get_audience(self, status):
@@ -156,7 +156,7 @@ class ActivityStream(RedisStore):
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)))
+ return list(set(audience) | set(status_author))
def get_stores_for_users(self, user_ids):
"""convert a list of user ids into redis store ids"""
@@ -183,15 +183,13 @@ class HomeStream(ActivityStream):
def get_audience(self, status):
trace.get_current_span().set_attribute("stream_id", self.key)
audience = super()._get_audience(status)
- if not audience:
- return []
# 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)))
+ return list(set(audience) | set(status_author))
def get_statuses_for_user(self, user):
return models.Status.privacy_filter(
@@ -239,9 +237,7 @@ class BooksStream(ActivityStream):
)
audience = super()._get_audience(status)
- if not audience:
- return models.User.objects.none()
- return audience.filter(shelfbook__book__parent_work=work).distinct()
+ return audience.filter(shelfbook__book__parent_work=work)
def get_audience(self, status):
# only show public statuses on the books feed,
@@ -329,10 +325,9 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
remove_status_task.delay(instance.id)
return
- # To avoid creating a zillion unnecessary tasks caused by re-saving the model,
- # check if it's actually ready to send before we go. We're trusting this was
- # set correctly by the inbox or view
- if not instance.ready:
+ # We don't want to create multiple add_status_tasks for each status, and because
+ # the transactions are atomic, on_commit won't run until the status is ready to add.
+ if not created:
return
# when creating new things, gotta wait on the transaction
diff --git a/bookwyrm/apps.py b/bookwyrm/apps.py
index b0c3e3fa4..d5384bb7b 100644
--- a/bookwyrm/apps.py
+++ b/bookwyrm/apps.py
@@ -1,4 +1,5 @@
"""Do further startup configuration and initialization"""
+
import os
import urllib
import logging
@@ -14,16 +15,16 @@ def download_file(url, destination):
"""Downloads a file to the given path"""
try:
# Ensure our destination directory exists
- os.makedirs(os.path.dirname(destination))
+ os.makedirs(os.path.dirname(destination), exist_ok=True)
with urllib.request.urlopen(url) as stream:
with open(destination, "b+w") as outfile:
outfile.write(stream.read())
- except (urllib.error.HTTPError, urllib.error.URLError):
- logger.info("Failed to download file %s", url)
- except OSError:
- logger.info("Couldn't open font file %s for writing", destination)
- except: # pylint: disable=bare-except
- logger.info("Unknown error in file download")
+ except (urllib.error.HTTPError, urllib.error.URLError) as err:
+ logger.error("Failed to download file %s: %s", url, err)
+ except OSError as err:
+ logger.error("Couldn't open font file %s for writing: %s", destination, err)
+ except Exception as err: # pylint:disable=broad-except
+ logger.error("Unknown error in file download: %s", err)
class BookwyrmConfig(AppConfig):
@@ -32,7 +33,6 @@ class BookwyrmConfig(AppConfig):
name = "bookwyrm"
verbose_name = "BookWyrm"
- # pylint: disable=no-self-use
def ready(self):
"""set up OTLP and preview image files, if desired"""
if settings.OTEL_EXPORTER_OTLP_ENDPOINT or settings.OTEL_EXPORTER_CONSOLE:
diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py
index 822c87f01..c11cdb8f1 100644
--- a/bookwyrm/book_search.py
+++ b/bookwyrm/book_search.py
@@ -1,35 +1,68 @@
""" using a bookwyrm instance as a source of book data """
+from __future__ import annotations
from dataclasses import asdict, dataclass
from functools import reduce
import operator
+from typing import Optional, Union, Any, Literal, overload
from django.contrib.postgres.search import SearchRank, SearchQuery
from django.db.models import F, Q
+from django.db.models.query import QuerySet
from bookwyrm import models
from bookwyrm import connectors
from bookwyrm.settings import MEDIA_FULL_URL
-# pylint: disable=arguments-differ
-def search(query, min_confidence=0, filters=None, return_first=False):
+@overload
+def search(
+ query: str,
+ *,
+ min_confidence: float = 0,
+ filters: Optional[list[Any]] = None,
+ return_first: Literal[False],
+) -> QuerySet[models.Edition]:
+ ...
+
+
+@overload
+def search(
+ query: str,
+ *,
+ min_confidence: float = 0,
+ filters: Optional[list[Any]] = None,
+ return_first: Literal[True],
+) -> Optional[models.Edition]:
+ ...
+
+
+def search(
+ query: str,
+ *,
+ min_confidence: float = 0,
+ filters: Optional[list[Any]] = None,
+ return_first: bool = False,
+ books: Optional[QuerySet[models.Edition]] = None,
+) -> Union[Optional[models.Edition], QuerySet[models.Edition]]:
"""search your local database"""
filters = filters or []
if not query:
- return []
+ return None if return_first else []
query = query.strip()
results = None
# 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)
+ results = search_identifiers(
+ query, *filters, return_first=return_first, books=books
+ )
# if there were no identifier results...
if not results:
# then try searching title/author
results = search_title_author(
- query, min_confidence, *filters, return_first=return_first
+ query, min_confidence, *filters, return_first=return_first, books=books
)
return results
@@ -66,8 +99,18 @@ def format_search_result(search_result):
).json()
-def search_identifiers(query, *filters, return_first=False):
- """tries remote_id, isbn; defined as dedupe fields on the model"""
+def search_identifiers(
+ query,
+ *filters,
+ return_first=False,
+ books=None,
+) -> Union[Optional[models.Edition], QuerySet[models.Edition]]:
+ """search Editions by deduplication fields
+
+ Best for cases when we can assume someone is searching for an exact match on
+ commonly unique data identifiers like isbn or specific library ids.
+ """
+ books = books or models.Edition.objects
if connectors.maybe_isbn(query):
# Oh did you think the 'S' in ISBN stood for 'standard'?
normalized_isbn = query.strip().upper().rjust(10, "0")
@@ -78,7 +121,7 @@ def search_identifiers(query, *filters, return_first=False):
for f in models.Edition._meta.get_fields()
if hasattr(f, "deduplication_field") and f.deduplication_field
]
- results = models.Edition.objects.filter(
+ results = books.filter(
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
).distinct()
@@ -87,11 +130,18 @@ def search_identifiers(query, *filters, return_first=False):
return results
-def search_title_author(query, min_confidence, *filters, return_first=False):
+def search_title_author(
+ query,
+ min_confidence,
+ *filters,
+ return_first=False,
+ books=None,
+) -> QuerySet[models.Edition]:
"""searches for title and author"""
+ books = books or models.Edition.objects
query = SearchQuery(query, config="simple") | SearchQuery(query, config="english")
results = (
- models.Edition.objects.filter(*filters, search_vector=query)
+ books.filter(*filters, search_vector=query)
.annotate(rank=SearchRank(F("search_vector"), query))
.filter(rank__gt=min_confidence)
.order_by("-rank")
@@ -102,7 +152,7 @@ def search_title_author(query, min_confidence, *filters, return_first=False):
# filter out multiple editions of the same work
list_results = []
- for work_id in set(editions_of_work[:30]):
+ for work_id in editions_of_work[:30]:
result = (
results.filter(parent_work=work_id)
.order_by("-rank", "-edition_rank")
@@ -122,11 +172,11 @@ class SearchResult:
title: str
key: str
connector: object
- view_link: str = None
- author: str = None
- year: str = None
- cover: str = None
- confidence: int = 1
+ view_link: Optional[str] = None
+ author: Optional[str] = None
+ year: Optional[str] = None
+ cover: Optional[str] = None
+ confidence: float = 1.0
def __repr__(self):
# pylint: disable=consider-using-f-string
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index 950bb11f9..aa8edbeae 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -1,7 +1,11 @@
""" functionality outline for a book data connector """
+from __future__ import annotations
from abc import ABC, abstractmethod
+from typing import Optional, TypedDict, Any, Callable, Union, Iterator
from urllib.parse import quote_plus
-import imghdr
+
+# pylint: disable-next=deprecated-module
+import imghdr # Deprecated in 3.11 for removal in 3.13; no good alternative yet
import logging
import re
import asyncio
@@ -16,33 +20,38 @@ 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
-
+from ..book_search import SearchResult
logger = logging.getLogger(__name__)
+JsonDict = dict[str, Any]
+
+
+class ConnectorResults(TypedDict):
+ """TypedDict for results returned by connector"""
+
+ connector: AbstractMinimalConnector
+ results: list[SearchResult]
+
class AbstractMinimalConnector(ABC):
"""just the bare bones, for other bookwyrm instances"""
- def __init__(self, identifier):
+ def __init__(self, identifier: str):
# load connector settings
info = models.Connector.objects.get(identifier=identifier)
self.connector = info
# the things in the connector model to copy over
- self_fields = [
- "base_url",
- "books_url",
- "covers_url",
- "search_url",
- "isbn_search_url",
- "name",
- "identifier",
- ]
- for field in self_fields:
- setattr(self, field, getattr(info, field))
+ self.base_url = info.base_url
+ self.books_url = info.books_url
+ self.covers_url = info.covers_url
+ self.search_url = info.search_url
+ self.isbn_search_url = info.isbn_search_url
+ self.name = info.name
+ self.identifier = info.identifier
- def get_search_url(self, query):
+ def get_search_url(self, query: str) -> str:
"""format the query url"""
# Check if the query resembles an ISBN
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
@@ -54,13 +63,21 @@ class AbstractMinimalConnector(ABC):
# searched as free text. This, instead, only searches isbn if it's isbn-y
return f"{self.search_url}{quote_plus(query)}"
- def process_search_response(self, query, data, min_confidence):
+ def process_search_response(
+ self, query: str, data: Any, min_confidence: float
+ ) -> list[SearchResult]:
"""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):
+ async def get_results(
+ self,
+ session: aiohttp.ClientSession,
+ url: str,
+ min_confidence: float,
+ query: str,
+ ) -> Optional[ConnectorResults]:
"""try this specific connector"""
# pylint: disable=line-too-long
headers = {
@@ -74,55 +91,63 @@ class AbstractMinimalConnector(ABC):
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
+ return None
try:
raw_data = await response.json()
except aiohttp.client_exceptions.ContentTypeError as err:
logger.exception(err)
- return
+ return None
- return {
- "connector": self,
- "results": self.process_search_response(
+ return ConnectorResults(
+ 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)
+ return None
@abstractmethod
- def get_or_create_book(self, remote_id):
+ def get_or_create_book(self, remote_id: str) -> Optional[models.Book]:
"""pull up a book record by whatever means possible"""
@abstractmethod
- def parse_search_data(self, data, min_confidence):
+ def parse_search_data(
+ self, data: Any, min_confidence: float
+ ) -> Iterator[SearchResult]:
"""turn the result json from a search into a list"""
@abstractmethod
- def parse_isbn_search_data(self, data):
+ def parse_isbn_search_data(self, data: Any) -> Iterator[SearchResult]:
"""turn the result json from a search into a list"""
class AbstractConnector(AbstractMinimalConnector):
"""generic book data connector"""
- def __init__(self, identifier):
+ generated_remote_link_field = ""
+
+ def __init__(self, identifier: str):
super().__init__(identifier)
# fields we want to look for in book data to copy over
# title we handle separately.
- self.book_mappings = []
+ self.book_mappings: list[Mapping] = []
+ self.author_mappings: list[Mapping] = []
- def get_or_create_book(self, remote_id):
+ def get_or_create_book(self, remote_id: str) -> Optional[models.Book]:
"""translate arbitrary json into an Activitypub dataclass"""
# first, check if we have the origin_id saved
existing = models.Edition.find_existing_by_remote_id(
remote_id
) or models.Work.find_existing_by_remote_id(remote_id)
if existing:
- if hasattr(existing, "default_edition"):
+ if hasattr(existing, "default_edition") and isinstance(
+ existing.default_edition, models.Edition
+ ):
return existing.default_edition
return existing
@@ -154,6 +179,9 @@ class AbstractConnector(AbstractMinimalConnector):
)
# this will dedupe automatically
work = work_activity.to_model(model=models.Work, overwrite=False)
+ if not work:
+ return None
+
for author in self.get_authors_from_data(work_data):
work.authors.add(author)
@@ -161,12 +189,21 @@ class AbstractConnector(AbstractMinimalConnector):
load_more_data.delay(self.connector.id, work.id)
return edition
- def get_book_data(self, remote_id): # pylint: disable=no-self-use
+ def get_book_data(self, remote_id: str) -> JsonDict: # pylint: disable=no-self-use
"""this allows connectors to override the default behavior"""
return get_data(remote_id)
- def create_edition_from_data(self, work, edition_data, instance=None):
+ def create_edition_from_data(
+ self,
+ work: models.Work,
+ edition_data: Union[str, JsonDict],
+ instance: Optional[models.Edition] = None,
+ ) -> Optional[models.Edition]:
"""if we already have the work, we're ready"""
+ if isinstance(edition_data, str):
+ # We don't expect a string here
+ return None
+
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
@@ -174,6 +211,9 @@ class AbstractConnector(AbstractMinimalConnector):
model=models.Edition, overwrite=False, instance=instance
)
+ if not edition:
+ return None
+
# if we're updating an existing instance, we don't need to load authors
if instance:
return edition
@@ -190,7 +230,9 @@ class AbstractConnector(AbstractMinimalConnector):
return edition
- def get_or_create_author(self, remote_id, instance=None):
+ def get_or_create_author(
+ self, remote_id: str, instance: Optional[models.Author] = None
+ ) -> Optional[models.Author]:
"""load that author"""
if not instance:
existing = models.Author.find_existing_by_remote_id(remote_id)
@@ -210,46 +252,51 @@ class AbstractConnector(AbstractMinimalConnector):
model=models.Author, overwrite=False, instance=instance
)
- def get_remote_id_from_model(self, obj):
+ def get_remote_id_from_model(self, obj: models.BookDataModel) -> Optional[str]:
"""given the data stored, how can we look this up"""
- return getattr(obj, getattr(self, "generated_remote_link_field"))
+ remote_id: Optional[str] = getattr(obj, self.generated_remote_link_field)
+ return remote_id
- def update_author_from_remote(self, obj):
+ def update_author_from_remote(self, obj: models.Author) -> Optional[models.Author]:
"""load the remote data from this connector and add it to an existing author"""
remote_id = self.get_remote_id_from_model(obj)
+ if not remote_id:
+ return None
return self.get_or_create_author(remote_id, instance=obj)
- def update_book_from_remote(self, obj):
+ def update_book_from_remote(self, obj: models.Edition) -> Optional[models.Edition]:
"""load the remote data from this connector and add it to an existing book"""
remote_id = self.get_remote_id_from_model(obj)
+ if not remote_id:
+ return None
data = self.get_book_data(remote_id)
return self.create_edition_from_data(obj.parent_work, data, instance=obj)
@abstractmethod
- def is_work_data(self, data):
+ def is_work_data(self, data: JsonDict) -> bool:
"""differentiate works and editions"""
@abstractmethod
- def get_edition_from_work_data(self, data):
+ def get_edition_from_work_data(self, data: JsonDict) -> JsonDict:
"""every work needs at least one edition"""
@abstractmethod
- def get_work_from_edition_data(self, data):
+ def get_work_from_edition_data(self, data: JsonDict) -> JsonDict:
"""every edition needs a work"""
@abstractmethod
- def get_authors_from_data(self, data):
+ def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]:
"""load author data"""
@abstractmethod
- def expand_book_data(self, book):
+ def expand_book_data(self, book: models.Book) -> None:
"""get more info on a book"""
-def dict_from_mappings(data, mappings):
+def dict_from_mappings(data: JsonDict, mappings: list[Mapping]) -> JsonDict:
"""create a dict in Activitypub format, using mappings supplies by
the subclass"""
- result = {}
+ result: JsonDict = {}
for mapping in mappings:
# sometimes there are multiple mappings for one field, don't
# overwrite earlier writes in that case
@@ -259,7 +306,11 @@ def dict_from_mappings(data, mappings):
return result
-def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
+def get_data(
+ url: str,
+ params: Optional[dict[str, str]] = None,
+ timeout: int = settings.QUERY_TIMEOUT,
+) -> JsonDict:
"""wrapper for request.get"""
# check if the url is blocked
raise_not_valid_url(url)
@@ -292,10 +343,15 @@ def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
logger.info(err)
raise ConnectorException(err)
+ if not isinstance(data, dict):
+ raise ConnectorException("Unexpected data format")
+
return data
-def get_image(url, timeout=10):
+def get_image(
+ url: str, timeout: int = 10
+) -> Union[tuple[ContentFile[bytes], str], tuple[None, None]]:
"""wrapper for requesting an image"""
raise_not_valid_url(url)
try:
@@ -325,14 +381,19 @@ def get_image(url, timeout=10):
class Mapping:
"""associate a local database field with a field in an external dataset"""
- def __init__(self, local_field, remote_field=None, formatter=None):
+ def __init__(
+ self,
+ local_field: str,
+ remote_field: Optional[str] = None,
+ formatter: Optional[Callable[[Any], Any]] = None,
+ ):
noop = lambda x: x
self.local_field = local_field
self.remote_field = remote_field or local_field
self.formatter = formatter or noop
- def get_value(self, data):
+ def get_value(self, data: JsonDict) -> Optional[Any]:
"""pull a field from incoming json and return the formatted version"""
value = data.get(self.remote_field)
if not value:
@@ -343,7 +404,7 @@ class Mapping:
return None
-def infer_physical_format(format_text):
+def infer_physical_format(format_text: str) -> Optional[str]:
"""try to figure out what the standardized format is from the free value"""
format_text = format_text.lower()
if format_text in format_mappings:
@@ -356,7 +417,7 @@ def infer_physical_format(format_text):
return matches[0]
-def unique_physical_format(format_text):
+def unique_physical_format(format_text: str) -> Optional[str]:
"""only store the format if it isn't directly in the format mappings"""
format_text = format_text.lower()
if format_text in format_mappings:
@@ -365,7 +426,7 @@ def unique_physical_format(format_text):
return format_text
-def maybe_isbn(query):
+def maybe_isbn(query: str) -> bool:
"""check if a query looks like an isbn"""
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
# ISBNs must be numeric except an ISBN10 checkdigit can be 'X'
diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py
index e07a0b281..4064f4b4c 100644
--- a/bookwyrm/connectors/bookwyrm_connector.py
+++ b/bookwyrm/connectors/bookwyrm_connector.py
@@ -1,4 +1,7 @@
""" using another bookwyrm instance as a source of book data """
+from __future__ import annotations
+from typing import Any, Iterator
+
from bookwyrm import activitypub, models
from bookwyrm.book_search import SearchResult
from .abstract_connector import AbstractMinimalConnector
@@ -7,15 +10,19 @@ from .abstract_connector import AbstractMinimalConnector
class Connector(AbstractMinimalConnector):
"""this is basically just for search"""
- def get_or_create_book(self, remote_id):
+ def get_or_create_book(self, remote_id: str) -> models.Edition:
return activitypub.resolve_remote_id(remote_id, model=models.Edition)
- def parse_search_data(self, data, min_confidence):
+ def parse_search_data(
+ self, data: list[dict[str, Any]], min_confidence: float
+ ) -> Iterator[SearchResult]:
for search_result in data:
search_result["connector"] = self
yield SearchResult(**search_result)
- def parse_isbn_search_data(self, data):
+ def parse_isbn_search_data(
+ self, data: list[dict[str, Any]]
+ ) -> Iterator[SearchResult]:
for search_result in data:
search_result["connector"] = self
yield SearchResult(**search_result)
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index e32da7c00..ad68af1dc 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -1,8 +1,11 @@
""" interface with whatever connectors the app has """
+from __future__ import annotations
import asyncio
import importlib
import ipaddress
import logging
+from asyncio import Future
+from typing import Iterator, Any, Optional, Union, overload, Literal
from urllib.parse import urlparse
import aiohttp
@@ -12,6 +15,8 @@ from django.db.models import signals
from requests import HTTPError
from bookwyrm import book_search, models
+from bookwyrm.book_search import SearchResult
+from bookwyrm.connectors import abstract_connector
from bookwyrm.settings import SEARCH_TIMEOUT
from bookwyrm.tasks import app, CONNECTORS
@@ -22,11 +27,15 @@ class ConnectorException(HTTPError):
"""when the connector can't do what was asked"""
-async def async_connector_search(query, items, min_confidence):
+async def async_connector_search(
+ query: str,
+ items: list[tuple[str, abstract_connector.AbstractConnector]],
+ min_confidence: float,
+) -> list[Optional[abstract_connector.ConnectorResults]]:
"""Try a number of requests simultaneously"""
timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
- tasks = []
+ tasks: list[Future[Optional[abstract_connector.ConnectorResults]]] = []
for url, connector in items:
tasks.append(
asyncio.ensure_future(
@@ -35,14 +44,29 @@ async def async_connector_search(query, items, min_confidence):
)
results = await asyncio.gather(*tasks)
- return results
+ return list(results)
-def search(query, min_confidence=0.1, return_first=False):
+@overload
+def search(
+ query: str, *, min_confidence: float = 0.1, return_first: Literal[False]
+) -> list[abstract_connector.ConnectorResults]:
+ ...
+
+
+@overload
+def search(
+ query: str, *, min_confidence: float = 0.1, return_first: Literal[True]
+) -> Optional[SearchResult]:
+ ...
+
+
+def search(
+ query: str, *, min_confidence: float = 0.1, return_first: bool = False
+) -> Union[list[abstract_connector.ConnectorResults], Optional[SearchResult]]:
"""find books based on arbitrary keywords"""
if not query:
- return []
- results = []
+ return None if return_first else []
items = []
for connector in get_connectors():
@@ -57,8 +81,12 @@ def search(query, min_confidence=0.1, return_first=False):
items.append((url, connector))
# load as many results as we can
- results = asyncio.run(async_connector_search(query, items, min_confidence))
- results = [r for r in results if r]
+ # failed requests will return None, so filter those out
+ results = [
+ r
+ for r in asyncio.run(async_connector_search(query, items, min_confidence))
+ if r
+ ]
if return_first:
# find the best result from all the responses and return that
@@ -66,11 +94,12 @@ def search(query, min_confidence=0.1, return_first=False):
all_results = sorted(all_results, key=lambda r: r.confidence, reverse=True)
return all_results[0] if all_results else None
- # failed requests will return None, so filter those out
return results
-def first_search_result(query, min_confidence=0.1):
+def first_search_result(
+ query: str, min_confidence: float = 0.1
+) -> Union[models.Edition, SearchResult, None]:
"""search until you find a result that fits"""
# try local search first
result = book_search.search(query, min_confidence=min_confidence, return_first=True)
@@ -80,18 +109,20 @@ def first_search_result(query, min_confidence=0.1):
return search(query, min_confidence=min_confidence, return_first=True) or None
-def get_connectors():
+def get_connectors() -> Iterator[abstract_connector.AbstractConnector]:
"""load all connectors"""
for info in models.Connector.objects.filter(active=True).order_by("priority").all():
yield load_connector(info)
-def get_or_create_connector(remote_id):
+def get_or_create_connector(remote_id: str) -> abstract_connector.AbstractConnector:
"""get the connector related to the object's server"""
url = urlparse(remote_id)
- identifier = url.netloc
+ identifier = url.hostname
if not identifier:
- raise ValueError("Invalid remote id")
+ raise ValueError(f"Invalid remote id: {remote_id}")
+
+ base_url = f"{url.scheme}://{url.netloc}"
try:
connector_info = models.Connector.objects.get(identifier=identifier)
@@ -99,10 +130,10 @@ def get_or_create_connector(remote_id):
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file="bookwyrm_connector",
- base_url=f"https://{identifier}",
- books_url=f"https://{identifier}/book",
- covers_url=f"https://{identifier}/images/covers",
- search_url=f"https://{identifier}/search?q=",
+ base_url=base_url,
+ books_url=f"{base_url}/book",
+ covers_url=f"{base_url}/images/covers",
+ search_url=f"{base_url}/search?q=",
priority=2,
)
@@ -110,47 +141,64 @@ def get_or_create_connector(remote_id):
@app.task(queue=CONNECTORS)
-def load_more_data(connector_id, book_id):
+def load_more_data(connector_id: str, book_id: str) -> None:
"""background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
- book = models.Book.objects.select_subclasses().get(id=book_id)
+ book = models.Book.objects.select_subclasses().get( # type: ignore[no-untyped-call]
+ id=book_id
+ )
connector.expand_book_data(book)
@app.task(queue=CONNECTORS)
-def create_edition_task(connector_id, work_id, data):
+def create_edition_task(
+ connector_id: int, work_id: int, data: Union[str, abstract_connector.JsonDict]
+) -> None:
"""separate task for each of the 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
- work = models.Work.objects.select_subclasses().get(id=work_id)
+ work = models.Work.objects.select_subclasses().get( # type: ignore[no-untyped-call]
+ id=work_id
+ )
connector.create_edition_from_data(work, data)
-def load_connector(connector_info):
+def load_connector(
+ connector_info: models.Connector,
+) -> abstract_connector.AbstractConnector:
"""instantiate the connector class"""
connector = importlib.import_module(
f"bookwyrm.connectors.{connector_info.connector_file}"
)
- return connector.Connector(connector_info.identifier)
+ return connector.Connector(connector_info.identifier) # type: ignore[no-any-return]
@receiver(signals.post_save, sender="bookwyrm.FederatedServer")
# pylint: disable=unused-argument
-def create_connector(sender, instance, created, *args, **kwargs):
+def create_connector(
+ sender: Any,
+ instance: models.FederatedServer,
+ created: Any,
+ *args: Any,
+ **kwargs: Any,
+) -> None:
"""create a connector to an external bookwyrm server"""
if instance.application_type == "bookwyrm":
get_or_create_connector(f"https://{instance.server_name}")
-def raise_not_valid_url(url):
+def raise_not_valid_url(url: str) -> None:
"""do some basic reality checks on the url"""
parsed = urlparse(url)
if not parsed.scheme in ["http", "https"]:
raise ConnectorException("Invalid scheme: ", url)
+ if not parsed.hostname:
+ raise ConnectorException("Hostname missing: ", url)
+
try:
- ipaddress.ip_address(parsed.netloc)
+ ipaddress.ip_address(parsed.hostname)
raise ConnectorException("Provided url is an IP address: ", url)
except ValueError:
# it's not an IP address, which is good
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index f3e24c0ec..249f6b9ca 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -1,9 +1,10 @@
""" inventaire data connector """
import re
+from typing import Any, Union, Optional, Iterator, Iterable
from bookwyrm import models
from bookwyrm.book_search import SearchResult
-from .abstract_connector import AbstractConnector, Mapping
+from .abstract_connector import AbstractConnector, Mapping, JsonDict
from .abstract_connector import get_data
from .connector_manager import ConnectorException, create_edition_task
@@ -13,7 +14,7 @@ class Connector(AbstractConnector):
generated_remote_link_field = "inventaire_id"
- def __init__(self, identifier):
+ def __init__(self, identifier: str):
super().__init__(identifier)
get_first = lambda a: a[0]
@@ -60,13 +61,13 @@ class Connector(AbstractConnector):
Mapping("died", remote_field="wdt:P570", formatter=get_first),
] + shared_mappings
- def get_remote_id(self, value):
+ def get_remote_id(self, value: str) -> str:
"""convert an id/uri into a url"""
return f"{self.books_url}?action=by-uris&uris={value}"
- def get_book_data(self, remote_id):
+ def get_book_data(self, remote_id: str) -> JsonDict:
data = get_data(remote_id)
- extracted = list(data.get("entities").values())
+ extracted = list(data.get("entities", {}).values())
try:
data = extracted[0]
except (KeyError, IndexError):
@@ -74,10 +75,16 @@ class Connector(AbstractConnector):
# flatten the data so that images, uri, and claims are on the same level
return {
**data.get("claims", {}),
- **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
+ **{
+ k: data.get(k)
+ for k in ["uri", "image", "labels", "sitelinks", "type"]
+ if k in data
+ },
}
- def parse_search_data(self, data, min_confidence):
+ def parse_search_data(
+ self, data: JsonDict, min_confidence: float
+ ) -> Iterator[SearchResult]:
for search_result in data.get("results", []):
images = search_result.get("image")
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
@@ -96,7 +103,7 @@ class Connector(AbstractConnector):
connector=self,
)
- def parse_isbn_search_data(self, data):
+ def parse_isbn_search_data(self, data: JsonDict) -> Iterator[SearchResult]:
"""got some data"""
results = data.get("entities")
if not results:
@@ -114,35 +121,44 @@ class Connector(AbstractConnector):
connector=self,
)
- def is_work_data(self, data):
+ def is_work_data(self, data: JsonDict) -> bool:
return data.get("type") == "work"
- def load_edition_data(self, work_uri):
+ def load_edition_data(self, work_uri: str) -> JsonDict:
"""get a list of editions for a work"""
# pylint: disable=line-too-long
url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
return get_data(url)
- def get_edition_from_work_data(self, data):
- data = self.load_edition_data(data.get("uri"))
+ def get_edition_from_work_data(self, data: JsonDict) -> JsonDict:
+ work_uri = data.get("uri")
+ if not work_uri:
+ raise ConnectorException("Invalid URI")
+ data = self.load_edition_data(work_uri)
try:
uri = data.get("uris", [])[0]
except IndexError:
raise ConnectorException("Invalid book data")
return self.get_book_data(self.get_remote_id(uri))
- def get_work_from_edition_data(self, data):
- uri = data.get("wdt:P629", [None])[0]
+ def get_work_from_edition_data(self, data: JsonDict) -> JsonDict:
+ try:
+ uri = data.get("wdt:P629", [])[0]
+ except IndexError:
+ raise ConnectorException("Invalid book data")
+
if not uri:
raise ConnectorException("Invalid book data")
return self.get_book_data(self.get_remote_id(uri))
- def get_authors_from_data(self, data):
+ def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]:
authors = data.get("wdt:P50", [])
for author in authors:
- yield self.get_or_create_author(self.get_remote_id(author))
+ model = self.get_or_create_author(self.get_remote_id(author))
+ if model:
+ yield model
- def expand_book_data(self, book):
+ def expand_book_data(self, book: models.Book) -> None:
work = book
# go from the edition to the work, if necessary
if isinstance(book, models.Edition):
@@ -154,11 +170,16 @@ class Connector(AbstractConnector):
# who knows, man
return
- for edition_uri in edition_options.get("uris"):
+ for edition_uri in edition_options.get("uris", []):
remote_id = self.get_remote_id(edition_uri)
create_edition_task.delay(self.connector.id, work.id, remote_id)
- def create_edition_from_data(self, work, edition_data, instance=None):
+ def create_edition_from_data(
+ self,
+ work: models.Work,
+ edition_data: Union[str, JsonDict],
+ instance: Optional[models.Edition] = None,
+ ) -> Optional[models.Edition]:
"""pass in the url as data and then call the version in abstract connector"""
if isinstance(edition_data, str):
try:
@@ -168,22 +189,26 @@ class Connector(AbstractConnector):
return None
return super().create_edition_from_data(work, edition_data, instance=instance)
- def get_cover_url(self, cover_blob, *_):
+ def get_cover_url(
+ self, cover_blob: Union[list[JsonDict], JsonDict], *_: Any
+ ) -> Optional[str]:
"""format the relative cover url into an absolute one:
{"url": "/img/entities/e794783f01b9d4f897a1ea9820b96e00d346994f"}
"""
# covers may or may not be a list
- if isinstance(cover_blob, list) and len(cover_blob) > 0:
+ if isinstance(cover_blob, list):
+ if len(cover_blob) == 0:
+ return None
cover_blob = cover_blob[0]
cover_id = cover_blob.get("url")
- if not cover_id:
+ if not isinstance(cover_id, str):
return None
# cover may or may not be an absolute url already
if re.match(r"^http", cover_id):
return cover_id
return f"{self.covers_url}{cover_id}"
- def resolve_keys(self, keys):
+ def resolve_keys(self, keys: Iterable[str]) -> list[str]:
"""cool, it's "wd:Q3156592" now what the heck does that mean"""
results = []
for uri in keys:
@@ -191,10 +216,10 @@ class Connector(AbstractConnector):
data = self.get_book_data(self.get_remote_id(uri))
except ConnectorException:
continue
- results.append(get_language_code(data.get("labels")))
+ results.append(get_language_code(data.get("labels", {})))
return results
- def get_description(self, links):
+ def get_description(self, links: JsonDict) -> str:
"""grab an extracted excerpt from wikipedia"""
link = links.get("enwiki")
if not link:
@@ -204,15 +229,15 @@ class Connector(AbstractConnector):
data = get_data(url)
except ConnectorException:
return ""
- return data.get("extract")
+ return str(data.get("extract", ""))
- def get_remote_id_from_model(self, obj):
+ def get_remote_id_from_model(self, obj: models.BookDataModel) -> str:
"""use get_remote_id to figure out the link from a model obj"""
remote_id_value = obj.inventaire_id
return self.get_remote_id(remote_id_value)
-def get_language_code(options, code="en"):
+def get_language_code(options: JsonDict, code: str = "en") -> Any:
"""when there are a bunch of translation but we need a single field"""
result = options.get(code)
if result:
diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
index 0fd786660..4dc6d6ac1 100644
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -1,9 +1,13 @@
""" openlibrary data connector """
import re
+from typing import Any, Optional, Union, Iterator, Iterable
+
+from markdown import markdown
from bookwyrm import models
from bookwyrm.book_search import SearchResult
-from .abstract_connector import AbstractConnector, Mapping
+from bookwyrm.utils.sanitizer import clean
+from .abstract_connector import AbstractConnector, Mapping, JsonDict
from .abstract_connector import get_data, infer_physical_format, unique_physical_format
from .connector_manager import ConnectorException, create_edition_task
from .openlibrary_languages import languages
@@ -14,7 +18,7 @@ class Connector(AbstractConnector):
generated_remote_link_field = "openlibrary_link"
- def __init__(self, identifier):
+ def __init__(self, identifier: str):
super().__init__(identifier)
get_first = lambda a, *args: a[0]
@@ -94,14 +98,14 @@ class Connector(AbstractConnector):
Mapping("inventaire_id", remote_field="links", formatter=get_inventaire_id),
]
- def get_book_data(self, remote_id):
+ def get_book_data(self, remote_id: str) -> JsonDict:
data = get_data(remote_id)
if data.get("type", {}).get("key") == "/type/redirect":
- remote_id = self.base_url + data.get("location")
+ remote_id = self.base_url + data.get("location", "")
return get_data(remote_id)
return data
- def get_remote_id_from_data(self, data):
+ def get_remote_id_from_data(self, data: JsonDict) -> str:
"""format a url from an openlibrary id field"""
try:
key = data["key"]
@@ -109,10 +113,10 @@ class Connector(AbstractConnector):
raise ConnectorException("Invalid book data")
return f"{self.books_url}{key}"
- def is_work_data(self, data):
+ def is_work_data(self, data: JsonDict) -> bool:
return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
- def get_edition_from_work_data(self, data):
+ def get_edition_from_work_data(self, data: JsonDict) -> JsonDict:
try:
key = data["key"]
except KeyError:
@@ -124,7 +128,7 @@ class Connector(AbstractConnector):
raise ConnectorException("No editions for work")
return edition
- def get_work_from_edition_data(self, data):
+ def get_work_from_edition_data(self, data: JsonDict) -> JsonDict:
try:
key = data["works"][0]["key"]
except (IndexError, KeyError):
@@ -132,7 +136,7 @@ class Connector(AbstractConnector):
url = f"{self.books_url}{key}"
return self.get_book_data(url)
- def get_authors_from_data(self, data):
+ def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]:
"""parse author json and load or create authors"""
for author_blob in data.get("authors", []):
author_blob = author_blob.get("author", author_blob)
@@ -144,7 +148,7 @@ class Connector(AbstractConnector):
continue
yield author
- def get_cover_url(self, cover_blob, size="L"):
+ def get_cover_url(self, cover_blob: list[str], size: str = "L") -> Optional[str]:
"""ask openlibrary for the cover"""
if not cover_blob:
return None
@@ -152,8 +156,10 @@ class Connector(AbstractConnector):
image_name = f"{cover_id}-{size}.jpg"
return f"{self.covers_url}/b/id/{image_name}"
- def parse_search_data(self, data, min_confidence):
- for idx, search_result in enumerate(data.get("docs")):
+ def parse_search_data(
+ self, data: JsonDict, min_confidence: float
+ ) -> Iterator[SearchResult]:
+ for idx, search_result in enumerate(data.get("docs", [])):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
@@ -174,7 +180,7 @@ class Connector(AbstractConnector):
confidence=confidence,
)
- def parse_isbn_search_data(self, data):
+ def parse_isbn_search_data(self, data: JsonDict) -> Iterator[SearchResult]:
for search_result in list(data.values()):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
@@ -188,12 +194,12 @@ class Connector(AbstractConnector):
year=search_result.get("publish_date"),
)
- def load_edition_data(self, olkey):
+ def load_edition_data(self, olkey: str) -> JsonDict:
"""query openlibrary for editions of a work"""
url = f"{self.books_url}/works/{olkey}/editions"
return self.get_book_data(url)
- def expand_book_data(self, book):
+ def expand_book_data(self, book: models.Book) -> None:
work = book
# go from the edition to the work, if necessary
if isinstance(book, models.Edition):
@@ -206,14 +212,14 @@ class Connector(AbstractConnector):
# who knows, man
return
- for edition_data in edition_options.get("entries"):
+ for edition_data in edition_options.get("entries", []):
# does this edition have ANY interesting data?
if ignore_edition(edition_data):
continue
create_edition_task.delay(self.connector.id, work.id, edition_data)
-def ignore_edition(edition_data):
+def ignore_edition(edition_data: JsonDict) -> bool:
"""don't load a million editions that have no metadata"""
# an isbn, we love to see it
if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
@@ -232,19 +238,30 @@ def ignore_edition(edition_data):
return True
-def get_description(description_blob):
+def get_description(description_blob: Union[JsonDict, str]) -> str:
"""descriptions can be a string or a dict"""
if isinstance(description_blob, dict):
- return description_blob.get("value")
- return description_blob
+ description = markdown(description_blob.get("value", ""))
+ else:
+ description = markdown(description_blob)
+
+ if (
+ description.startswith("
")
+ and description.endswith("
")
+ and description.count("") == 1
+ ):
+ # If there is just one
tag and it is around the text remove it
+ return description[len("
") : -len("
")].strip()
+
+ return clean(description)
-def get_openlibrary_key(key):
+def get_openlibrary_key(key: str) -> str:
"""convert /books/OL27320736M into OL27320736M"""
return key.split("/")[-1]
-def get_languages(language_blob):
+def get_languages(language_blob: Iterable[JsonDict]) -> list[Optional[str]]:
"""/language/eng -> English"""
langs = []
for lang in language_blob:
@@ -252,14 +269,14 @@ def get_languages(language_blob):
return langs
-def get_dict_field(blob, field_name):
+def get_dict_field(blob: Optional[JsonDict], field_name: str) -> Optional[Any]:
"""extract the isni from the remote id data for the author"""
if not blob or not isinstance(blob, dict):
return None
return blob.get(field_name)
-def get_wikipedia_link(links):
+def get_wikipedia_link(links: list[Any]) -> Optional[str]:
"""extract wikipedia links"""
if not isinstance(links, list):
return None
@@ -272,7 +289,7 @@ def get_wikipedia_link(links):
return None
-def get_inventaire_id(links):
+def get_inventaire_id(links: list[Any]) -> Optional[str]:
"""extract and format inventaire ids"""
if not isinstance(links, list):
return None
@@ -282,11 +299,13 @@ def get_inventaire_id(links):
continue
if link.get("title") == "inventaire.io":
iv_link = link.get("url")
+ if not isinstance(iv_link, str):
+ return None
return iv_link.split("/")[-1]
return None
-def pick_default_edition(options):
+def pick_default_edition(options: list[JsonDict]) -> Optional[JsonDict]:
"""favor physical copies with covers in english"""
if not options:
return None
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index 0047bfce1..bec704a2c 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -2,7 +2,7 @@
from bookwyrm import models, settings
-def site_settings(request): # pylint: disable=unused-argument
+def site_settings(request):
"""include the custom info about the site"""
request_protocol = "https://"
if not request.is_secure():
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index 5e08ebba1..ccc0aea61 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -4,7 +4,7 @@ from django.template.loader import get_template
from bookwyrm import models, settings
from bookwyrm.tasks import app, EMAIL
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import DOMAIN, BASE_URL
def email_data():
@@ -14,6 +14,7 @@ def email_data():
"site_name": site.name,
"logo": site.logo_small_url,
"domain": DOMAIN,
+ "base_url": BASE_URL,
"user": None,
}
diff --git a/bookwyrm/forms/author.py b/bookwyrm/forms/author.py
index 5b54a07b5..a3a759af7 100644
--- a/bookwyrm/forms/author.py
+++ b/bookwyrm/forms/author.py
@@ -15,6 +15,7 @@ class AuthorForm(CustomForm):
"aliases",
"bio",
"wikipedia_link",
+ "wikidata",
"website",
"born",
"died",
@@ -32,6 +33,7 @@ class AuthorForm(CustomForm):
"wikipedia_link": forms.TextInput(
attrs={"aria-describedby": "desc_wikipedia_link"}
),
+ "wikidata": forms.TextInput(attrs={"aria-describedby": "desc_wikidata"}),
"website": forms.TextInput(attrs={"aria-describedby": "desc_website"}),
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
diff --git a/bookwyrm/forms/books.py b/bookwyrm/forms/books.py
index 3a3979e2c..f73ce3f5a 100644
--- a/bookwyrm/forms/books.py
+++ b/bookwyrm/forms/books.py
@@ -1,8 +1,9 @@
""" using django model forms """
from django import forms
+from file_resubmit.widgets import ResubmitImageWidget
+
from bookwyrm import models
-from bookwyrm.models.fields import ClearableFileInputWithWarning
from .custom_form import CustomForm
from .widgets import ArrayWidget, SelectDateWidget, Select
@@ -70,9 +71,7 @@ class EditionForm(CustomForm):
"published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
- "cover": ClearableFileInputWithWarning(
- attrs={"aria-describedby": "desc_cover"}
- ),
+ "cover": ResubmitImageWidget(attrs={"aria-describedby": "desc_cover"}),
"physical_format": Select(
attrs={"aria-describedby": "desc_physical_format"}
),
@@ -111,6 +110,7 @@ class EditionFromWorkForm(CustomForm):
model = models.Work
fields = [
"title",
+ "sort_title",
"subtitle",
"authors",
"description",
diff --git a/bookwyrm/forms/custom_form.py b/bookwyrm/forms/custom_form.py
index c604deea4..6b425d216 100644
--- a/bookwyrm/forms/custom_form.py
+++ b/bookwyrm/forms/custom_form.py
@@ -15,9 +15,9 @@ class StyledForm(ModelForm):
css_classes["number"] = "input"
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
- # pylint: disable=super-with-arguments
super().__init__(*args, **kwargs)
for visible in self.visible_fields():
+ input_type = ""
if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea):
diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py
index ce7bb6d07..9024972c3 100644
--- a/bookwyrm/forms/edit_user.py
+++ b/bookwyrm/forms/edit_user.py
@@ -70,6 +70,22 @@ class DeleteUserForm(CustomForm):
fields = ["password"]
+class MoveUserForm(CustomForm):
+ target = forms.CharField(widget=forms.TextInput)
+
+ class Meta:
+ model = models.User
+ fields = ["password"]
+
+
+class AliasUserForm(CustomForm):
+ username = forms.CharField(widget=forms.TextInput)
+
+ class Meta:
+ model = models.User
+ fields = ["password"]
+
+
class ChangePasswordForm(CustomForm):
current_password = forms.CharField(widget=forms.PasswordInput)
confirm_password = forms.CharField(widget=forms.PasswordInput)
diff --git a/bookwyrm/forms/forms.py b/bookwyrm/forms/forms.py
index ea6093750..3d555f308 100644
--- a/bookwyrm/forms/forms.py
+++ b/bookwyrm/forms/forms.py
@@ -25,6 +25,10 @@ class ImportForm(forms.Form):
csv_file = forms.FileField()
+class ImportUserForm(forms.Form):
+ archive_file = forms.FileField()
+
+
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py
index 1da4fc4f1..831d1d539 100644
--- a/bookwyrm/forms/landing.py
+++ b/bookwyrm/forms/landing.py
@@ -34,7 +34,6 @@ class LoginForm(CustomForm):
def add_invalid_password_error(self):
"""We don't want to be too specific about this"""
- # pylint: disable=attribute-defined-outside-init
self.non_field_errors = _("Username or password are incorrect")
diff --git a/bookwyrm/forms/links.py b/bookwyrm/forms/links.py
index d2fd5f116..5156d2578 100644
--- a/bookwyrm/forms/links.py
+++ b/bookwyrm/forms/links.py
@@ -1,4 +1,5 @@
""" using django model forms """
+
from urllib.parse import urlparse
from django.utils.translation import gettext_lazy as _
@@ -25,7 +26,7 @@ class FileLinkForm(CustomForm):
url = cleaned_data.get("url")
filetype = cleaned_data.get("filetype")
book = cleaned_data.get("book")
- domain = urlparse(url).netloc
+ domain = urlparse(url).hostname
if models.LinkDomain.objects.filter(domain=domain).exists():
status = models.LinkDomain.objects.get(domain=domain).status
if status == "blocked":
@@ -37,10 +38,9 @@ class FileLinkForm(CustomForm):
),
)
if (
- not self.instance
- and models.FileLink.objects.filter(
- url=url, book=book, filetype=filetype
- ).exists()
+ models.FileLink.objects.filter(url=url, book=book, filetype=filetype)
+ .exclude(pk=self.instance)
+ .exists()
):
# pylint: disable=line-too-long
self.add_error(
diff --git a/bookwyrm/forms/widgets.py b/bookwyrm/forms/widgets.py
index ee9345aa0..001fdbec4 100644
--- a/bookwyrm/forms/widgets.py
+++ b/bookwyrm/forms/widgets.py
@@ -5,8 +5,6 @@ from django import forms
class ArrayWidget(forms.widgets.TextInput):
"""Inputs for postgres array fields"""
- # pylint: disable=unused-argument
- # pylint: disable=no-self-use
def value_from_datadict(self, data, files, name):
"""get all values for this name"""
return [i for i in data.getlist(name) if i]
diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py
index 6ce50f160..3c895741b 100644
--- a/bookwyrm/importers/__init__.py
+++ b/bookwyrm/importers/__init__.py
@@ -1,6 +1,7 @@
""" import classes """
from .importer import Importer
+from .bookwyrm_import import BookwyrmImporter, BookwyrmBooksImporter
from .calibre_import import CalibreImporter
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
diff --git a/bookwyrm/importers/bookwyrm_import.py b/bookwyrm/importers/bookwyrm_import.py
new file mode 100644
index 000000000..8afc1abf7
--- /dev/null
+++ b/bookwyrm/importers/bookwyrm_import.py
@@ -0,0 +1,39 @@
+"""Import data from Bookwyrm export files"""
+from django.http import QueryDict
+
+from bookwyrm.models import User
+from bookwyrm.models.bookwyrm_import_job import BookwyrmImportJob
+from . import Importer
+
+
+class BookwyrmImporter:
+ """Import a Bookwyrm User export file.
+ This is kind of a combination of an importer and a connector.
+ """
+
+ # pylint: disable=no-self-use
+ def process_import(
+ self, user: User, archive_file: bytes, settings: QueryDict
+ ) -> BookwyrmImportJob:
+ """import user data from a Bookwyrm export file"""
+
+ required = [k for k in settings if settings.get(k) == "on"]
+
+ job = BookwyrmImportJob.objects.create(
+ user=user, archive_file=archive_file, required=required
+ )
+ return job
+
+
+class BookwyrmBooksImporter(Importer):
+ """
+ Handle reading a csv from BookWyrm.
+ Goodreads is the default importer, we basically just use the same structure
+ But BookWyrm has additional attributes in the csv
+ """
+
+ service = "BookWyrm"
+ row_mappings_guesses = Importer.row_mappings_guesses + [
+ ("shelf_name", ["shelf_name"]),
+ ("review_published", ["review_published"]),
+ ]
diff --git a/bookwyrm/importers/calibre_import.py b/bookwyrm/importers/calibre_import.py
index 5426e9333..542175dd7 100644
--- a/bookwyrm/importers/calibre_import.py
+++ b/bookwyrm/importers/calibre_import.py
@@ -1,4 +1,6 @@
""" handle reading a csv from calibre """
+from typing import Any, Optional
+
from bookwyrm.models import Shelf
from . import Importer
@@ -9,20 +11,15 @@ class CalibreImporter(Importer):
service = "Calibre"
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args: Any, **kwargs: Any):
# Add timestamp to row_mappings_guesses for date_added to avoid
# integrity error
- row_mappings_guesses = []
-
- for field, mapping in self.row_mappings_guesses:
- if field in ("date_added",):
- row_mappings_guesses.append((field, mapping + ["timestamp"]))
- else:
- row_mappings_guesses.append((field, mapping))
-
- self.row_mappings_guesses = row_mappings_guesses
+ self.row_mappings_guesses = [
+ (field, mapping + (["timestamp"] if field == "date_added" else []))
+ for field, mapping in self.row_mappings_guesses
+ ]
super().__init__(*args, **kwargs)
- def get_shelf(self, normalized_row):
+ def get_shelf(self, normalized_row: dict[str, Optional[str]]) -> Optional[str]:
# Calibre export does not indicate which shelf to use. Use a default one for now
return Shelf.TO_READ
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index 4c2abb521..d2a11d7f2 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -1,8 +1,10 @@
""" handle reading a csv from an external service, defaults are from Goodreads """
import csv
from datetime import timedelta
+from typing import Iterable, Optional
+
from django.utils import timezone
-from bookwyrm.models import ImportJob, ImportItem, SiteSettings
+from bookwyrm.models import ImportJob, ImportItem, SiteSettings, User
class Importer:
@@ -16,17 +18,26 @@ class Importer:
row_mappings_guesses = [
("id", ["id", "book id"]),
("title", ["title"]),
- ("authors", ["author", "authors", "primary author"]),
- ("isbn_10", ["isbn10", "isbn", "isbn/uid"]),
- ("isbn_13", ["isbn13", "isbn", "isbns", "isbn/uid"]),
+ ("authors", ["author_text", "author", "authors", "primary author"]),
+ ("isbn_10", ["isbn_10", "isbn10", "isbn", "isbn/uid"]),
+ ("isbn_13", ["isbn_13", "isbn13", "isbn", "isbns", "isbn/uid"]),
("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
- ("review_name", ["review name"]),
- ("review_body", ["my review", "review"]),
+ ("review_name", ["review_name", "review name"]),
+ ("review_body", ["review_content", "my review", "review"]),
("rating", ["my rating", "rating", "star rating"]),
- ("date_added", ["date added", "entry date", "added"]),
- ("date_started", ["date started", "started"]),
- ("date_finished", ["date finished", "last date read", "date read", "finished"]),
+ (
+ "date_added",
+ ["shelf_date", "date_added", "date added", "entry date", "added"],
+ ),
+ ("date_started", ["start_date", "date started", "started"]),
+ (
+ "date_finished",
+ ["finish_date", "date finished", "last date read", "date read", "finished"],
+ ),
]
+
+ # TODO: stopped
+
date_fields = ["date_added", "date_started", "date_finished"]
shelf_mapping_guesses = {
"to-read": ["to-read", "want to read"],
@@ -34,20 +45,33 @@ class Importer:
"reading": ["currently-reading", "reading", "currently reading"],
}
- # pylint: disable=too-many-locals
- def create_job(self, user, csv_file, include_reviews, privacy):
+ # pylint: disable=too-many-arguments
+ def create_job(
+ self,
+ user: User,
+ csv_file: Iterable[str],
+ include_reviews: bool,
+ privacy: str,
+ create_shelves: bool = True,
+ ) -> ImportJob:
"""check over a csv and creates a database entry for the job"""
csv_reader = csv.DictReader(csv_file, delimiter=self.delimiter)
rows = list(csv_reader)
if len(rows) < 1:
raise ValueError("CSV file is empty")
- rows = enumerate(rows)
+
+ mappings = (
+ self.create_row_mappings(list(fieldnames))
+ if (fieldnames := csv_reader.fieldnames)
+ else {}
+ )
job = ImportJob.objects.create(
user=user,
include_reviews=include_reviews,
+ create_shelves=create_shelves,
privacy=privacy,
- mappings=self.create_row_mappings(csv_reader.fieldnames),
+ mappings=mappings,
source=self.service,
)
@@ -55,16 +79,20 @@ class Importer:
if enforce_limit and allowed_imports <= 0:
job.complete_job()
return job
- for index, entry in rows:
+ for index, entry in enumerate(rows):
if enforce_limit and index >= allowed_imports:
break
self.create_item(job, index, entry)
return job
- def update_legacy_job(self, job):
+ def update_legacy_job(self, job: ImportJob) -> None:
"""patch up a job that was in the old format"""
items = job.items
- headers = list(items.first().data.keys())
+ first_item = items.first()
+ if first_item is None:
+ return
+
+ headers = list(first_item.data.keys())
job.mappings = self.create_row_mappings(headers)
job.updated_date = timezone.now()
job.save()
@@ -75,24 +103,24 @@ class Importer:
item.normalized_data = normalized
item.save()
- def create_row_mappings(self, headers):
+ def create_row_mappings(self, headers: list[str]) -> dict[str, Optional[str]]:
"""guess what the headers mean"""
mappings = {}
for (key, guesses) in self.row_mappings_guesses:
- value = [h for h in headers if h.lower() in guesses]
- value = value[0] if len(value) else None
+ values = [h for h in headers if h.lower() in guesses]
+ value = values[0] if len(values) else None
if value:
headers.remove(value)
mappings[key] = value
return mappings
- def create_item(self, job, index, data):
+ def create_item(self, job: ImportJob, index: int, data: dict[str, str]) -> None:
"""creates and saves an import item"""
normalized = self.normalize_row(data, job.mappings)
normalized["shelf"] = self.get_shelf(normalized)
ImportItem(job=job, index=index, data=data, normalized_data=normalized).save()
- def get_shelf(self, normalized_row):
+ def get_shelf(self, normalized_row: dict[str, Optional[str]]) -> Optional[str]:
"""determine which shelf to use"""
shelf_name = normalized_row.get("shelf")
if not shelf_name:
@@ -101,13 +129,17 @@ class Importer:
shelf = [
s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs
]
- return shelf[0] if shelf else None
+ return shelf[0] if shelf else normalized_row.get("shelf") or None
- def normalize_row(self, entry, mappings): # pylint: disable=no-self-use
+ # pylint: disable=no-self-use
+ def normalize_row(
+ self, entry: dict[str, str], mappings: dict[str, Optional[str]]
+ ) -> dict[str, Optional[str]]:
"""use the dataclass to create the formatted row of data"""
- return {k: entry.get(v) for k, v in mappings.items()}
+ return {k: entry.get(v) if v else None for k, v in mappings.items()}
- def get_import_limit(self, user): # pylint: disable=no-self-use
+ # pylint: disable=no-self-use
+ def get_import_limit(self, user: User) -> tuple[int, int]:
"""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
@@ -125,11 +157,14 @@ class Importer:
allowed_imports = import_size_limit - imported_books
return enforce_limit, allowed_imports
- def create_retry_job(self, user, original_job, items):
+ def create_retry_job(
+ self, user: User, original_job: ImportJob, items: list[ImportItem]
+ ) -> ImportJob:
"""retry items that didn't import"""
job = ImportJob.objects.create(
user=user,
include_reviews=original_job.include_reviews,
+ create_shelves=original_job.create_shelves,
privacy=original_job.privacy,
source=original_job.source,
# TODO: allow users to adjust mappings
diff --git a/bookwyrm/importers/librarything_import.py b/bookwyrm/importers/librarything_import.py
index ea31b46eb..24a2626bf 100644
--- a/bookwyrm/importers/librarything_import.py
+++ b/bookwyrm/importers/librarything_import.py
@@ -1,11 +1,16 @@
""" handle reading a tsv from librarything """
import re
+from typing import Optional
from bookwyrm.models import Shelf
from . import Importer
+def _remove_brackets(value: Optional[str]) -> Optional[str]:
+ return re.sub(r"\[|\]", "", value) if value else None
+
+
class LibrarythingImporter(Importer):
"""csv downloads from librarything"""
@@ -13,16 +18,19 @@ class LibrarythingImporter(Importer):
delimiter = "\t"
encoding = "ISO-8859-1"
- def normalize_row(self, entry, mappings): # pylint: disable=no-self-use
+ def normalize_row(
+ self, entry: dict[str, str], mappings: dict[str, Optional[str]]
+ ) -> dict[str, Optional[str]]:
"""use the dataclass to create the formatted row of data"""
- remove_brackets = lambda v: re.sub(r"\[|\]", "", v) if v else None
- 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 = {
+ k: _remove_brackets(entry.get(v) if v else None)
+ for k, v in mappings.items()
+ }
+ isbn_13 = value.split(", ") if (value := normalized.get("isbn_13")) else []
normalized["isbn_13"] = isbn_13[1] if len(isbn_13) > 1 else None
return normalized
- def get_shelf(self, normalized_row):
+ def get_shelf(self, normalized_row: dict[str, Optional[str]]) -> Optional[str]:
if normalized_row["date_finished"]:
return Shelf.READ_FINISHED
if normalized_row["date_started"]:
diff --git a/bookwyrm/importers/openlibrary_import.py b/bookwyrm/importers/openlibrary_import.py
index ef1030609..6a954ed3c 100644
--- a/bookwyrm/importers/openlibrary_import.py
+++ b/bookwyrm/importers/openlibrary_import.py
@@ -1,4 +1,6 @@
""" handle reading a csv from openlibrary"""
+from typing import Any
+
from . import Importer
@@ -7,7 +9,7 @@ class OpenLibraryImporter(Importer):
service = "OpenLibrary"
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args: Any, **kwargs: Any):
self.row_mappings_guesses.append(("openlibrary_key", ["edition id"]))
self.row_mappings_guesses.append(("openlibrary_work_key", ["work id"]))
super().__init__(*args, **kwargs)
diff --git a/bookwyrm/isbn/RangeMessage.xml b/bookwyrm/isbn/RangeMessage.xml
new file mode 100644
index 000000000..619cf1ff7
--- /dev/null
+++ b/bookwyrm/isbn/RangeMessage.xml
@@ -0,0 +1,7904 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+]>
+
+ International ISBN Agency
+ fa1a5bb4-9703-4910-bd34-2ffe0ae46c45
+ Sat, 22 Jul 2023 02:00:37 BST
+
+
+ 978
+ International ISBN Agency
+
+
+ 0000000-5999999
+ 1
+
+
+ 6000000-6499999
+ 3
+
+
+ 6500000-6599999
+ 2
+
+
+ 6600000-6999999
+ 0
+
+
+ 7000000-7999999
+ 1
+
+
+ 8000000-9499999
+ 2
+
+
+ 9500000-9899999
+ 3
+
+
+ 9900000-9989999
+ 4
+
+
+ 9990000-9999999
+ 5
+
+
+
+
+ 979
+ International ISBN Agency
+
+
+ 0000000-0999999
+ 0
+
+
+ 1000000-1299999
+ 2
+
+
+ 1300000-7999999
+ 0
+
+
+ 8000000-8999999
+ 1
+
+
+ 9000000-9999999
+ 0
+
+
+
+
+
+
+ 978-0
+ English language
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-2279999
+ 3
+
+
+ 2280000-2289999
+ 4
+
+
+ 2290000-3689999
+ 3
+
+
+ 3690000-3699999
+ 4
+
+
+ 3700000-6389999
+ 3
+
+
+ 6390000-6397999
+ 4
+
+
+ 6398000-6399999
+ 7
+
+
+ 6400000-6449999
+ 3
+
+
+ 6450000-6459999
+ 7
+
+
+ 6460000-6479999
+ 3
+
+
+ 6480000-6489999
+ 7
+
+
+ 6490000-6549999
+ 3
+
+
+ 6550000-6559999
+ 4
+
+
+ 6560000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9499999
+ 6
+
+
+ 9500000-9999999
+ 7
+
+
+
+
+ 978-1
+ English language
+
+
+ 0000000-0099999
+ 3
+
+
+ 0100000-0299999
+ 2
+
+
+ 0300000-0349999
+ 3
+
+
+ 0350000-0399999
+ 4
+
+
+ 0400000-0499999
+ 3
+
+
+ 0500000-0699999
+ 2
+
+
+ 0700000-0999999
+ 4
+
+
+ 1000000-3979999
+ 3
+
+
+ 3980000-5499999
+ 4
+
+
+ 5500000-6499999
+ 5
+
+
+ 6500000-6799999
+ 4
+
+
+ 6800000-6859999
+ 5
+
+
+ 6860000-7139999
+ 4
+
+
+ 7140000-7169999
+ 3
+
+
+ 7170000-7319999
+ 4
+
+
+ 7320000-7399999
+ 7
+
+
+ 7400000-7749999
+ 5
+
+
+ 7750000-7753999
+ 7
+
+
+ 7754000-7763999
+ 5
+
+
+ 7764000-7764999
+ 7
+
+
+ 7765000-7769999
+ 5
+
+
+ 7770000-7782999
+ 7
+
+
+ 7783000-7899999
+ 5
+
+
+ 7900000-7999999
+ 4
+
+
+ 8000000-8004999
+ 5
+
+
+ 8005000-8049999
+ 5
+
+
+ 8050000-8379999
+ 5
+
+
+ 8380000-8384999
+ 7
+
+
+ 8385000-8671999
+ 5
+
+
+ 8672000-8675999
+ 4
+
+
+ 8676000-8697999
+ 5
+
+
+ 8698000-9159999
+ 6
+
+
+ 9160000-9165059
+ 7
+
+
+ 9165060-9168699
+ 6
+
+
+ 9168700-9169079
+ 7
+
+
+ 9169080-9195999
+ 6
+
+
+ 9196000-9196549
+ 7
+
+
+ 9196550-9729999
+ 6
+
+
+ 9730000-9877999
+ 4
+
+
+ 9878000-9911499
+ 6
+
+
+ 9911500-9911999
+ 7
+
+
+ 9912000-9989899
+ 6
+
+
+ 9989900-9999999
+ 7
+
+
+
+
+ 978-2
+ French language
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-3499999
+ 3
+
+
+ 3500000-3999999
+ 5
+
+
+ 4000000-4869999
+ 3
+
+
+ 4870000-4949999
+ 6
+
+
+ 4950000-4959999
+ 3
+
+
+ 4960000-4966999
+ 4
+
+
+ 4967000-4969999
+ 5
+
+
+ 4970000-5279999
+ 3
+
+
+ 5280000-5299999
+ 4
+
+
+ 5300000-6999999
+ 3
+
+
+ 7000000-8399999
+ 4
+
+
+ 8400000-8999999
+ 5
+
+
+ 9000000-9197999
+ 6
+
+
+ 9198000-9198099
+ 5
+
+
+ 9198100-9199429
+ 6
+
+
+ 9199430-9199689
+ 7
+
+
+ 9199690-9499999
+ 6
+
+
+ 9500000-9999999
+ 7
+
+
+
+
+ 978-3
+ German language
+
+
+ 0000000-0299999
+ 2
+
+
+ 0300000-0339999
+ 3
+
+
+ 0340000-0369999
+ 4
+
+
+ 0370000-0399999
+ 5
+
+
+ 0400000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9499999
+ 6
+
+
+ 9500000-9539999
+ 7
+
+
+ 9540000-9699999
+ 5
+
+
+ 9700000-9849999
+ 7
+
+
+ 9850000-9999999
+ 5
+
+
+
+
+ 978-4
+ Japan
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9499999
+ 6
+
+
+ 9500000-9999999
+ 7
+
+
+
+
+ 978-5
+ former U.S.S.R
+
+
+ 0000000-0049999
+ 5
+
+
+ 0050000-0099999
+ 4
+
+
+ 0100000-1999999
+ 2
+
+
+ 2000000-3619999
+ 3
+
+
+ 3620000-3623999
+ 4
+
+
+ 3624000-3629999
+ 5
+
+
+ 3630000-4209999
+ 3
+
+
+ 4210000-4299999
+ 4
+
+
+ 4300000-4309999
+ 3
+
+
+ 4310000-4399999
+ 4
+
+
+ 4400000-4409999
+ 3
+
+
+ 4410000-4499999
+ 4
+
+
+ 4500000-6039999
+ 3
+
+
+ 6040000-6049999
+ 7
+
+
+ 6050000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9099999
+ 6
+
+
+ 9100000-9199999
+ 5
+
+
+ 9200000-9299999
+ 4
+
+
+ 9300000-9499999
+ 5
+
+
+ 9500000-9500999
+ 7
+
+
+ 9501000-9799999
+ 4
+
+
+ 9800000-9899999
+ 5
+
+
+ 9900000-9909999
+ 7
+
+
+ 9910000-9999999
+ 4
+
+
+
+
+ 978-600
+ Iran
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-4999999
+ 3
+
+
+ 5000000-8999999
+ 4
+
+
+ 9000000-9867999
+ 5
+
+
+ 9868000-9929999
+ 4
+
+
+ 9930000-9959999
+ 3
+
+
+ 9960000-9999999
+ 5
+
+
+
+
+ 978-601
+ Kazakhstan
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-7999999
+ 4
+
+
+ 8000000-8499999
+ 5
+
+
+ 8500000-9999999
+ 2
+
+
+
+
+ 978-602
+ Indonesia
+
+
+ 0000000-0699999
+ 2
+
+
+ 0700000-1399999
+ 4
+
+
+ 1400000-1499999
+ 5
+
+
+ 1500000-1699999
+ 4
+
+
+ 1700000-1999999
+ 5
+
+
+ 2000000-4999999
+ 3
+
+
+ 5000000-5399999
+ 5
+
+
+ 5400000-5999999
+ 4
+
+
+ 6000000-6199999
+ 5
+
+
+ 6200000-6999999
+ 4
+
+
+ 7000000-7499999
+ 5
+
+
+ 7500000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-603
+ Saudi Arabia
+
+
+ 0000000-0499999
+ 2
+
+
+ 0500000-4999999
+ 2
+
+
+ 5000000-7999999
+ 3
+
+
+ 8000000-8999999
+ 4
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-604
+ Vietnam
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-3999999
+ 3
+
+
+ 4000000-4699999
+ 2
+
+
+ 4700000-4979999
+ 3
+
+
+ 4980000-4999999
+ 4
+
+
+ 5000000-8999999
+ 2
+
+
+ 9000000-9799999
+ 3
+
+
+ 9800000-9999999
+ 4
+
+
+
+
+ 978-605
+ Turkey
+
+
+ 0000000-0299999
+ 2
+
+
+ 0300000-0399999
+ 3
+
+
+ 0400000-0599999
+ 2
+
+
+ 0600000-0699999
+ 5
+
+
+ 0700000-0999999
+ 2
+
+
+ 1000000-1999999
+ 3
+
+
+ 2000000-2399999
+ 4
+
+
+ 2400000-3999999
+ 3
+
+
+ 4000000-5999999
+ 4
+
+
+ 6000000-7499999
+ 5
+
+
+ 7500000-7999999
+ 4
+
+
+ 8000000-8999999
+ 5
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-606
+ Romania
+
+
+ 0000000-0999999
+ 3
+
+
+ 1000000-4999999
+ 2
+
+
+ 5000000-7999999
+ 3
+
+
+ 8000000-9099999
+ 4
+
+
+ 9100000-9199999
+ 3
+
+
+ 9200000-9599999
+ 5
+
+
+ 9600000-9749999
+ 4
+
+
+ 9750000-9999999
+ 3
+
+
+
+
+ 978-607
+ Mexico
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-5929999
+ 3
+
+
+ 5930000-5999999
+ 5
+
+
+ 6000000-7499999
+ 3
+
+
+ 7500000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-608
+ North Macedonia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1999999
+ 2
+
+
+ 2000000-4499999
+ 3
+
+
+ 4500000-6499999
+ 4
+
+
+ 6500000-6999999
+ 5
+
+
+ 7000000-9999999
+ 1
+
+
+
+
+ 978-609
+ Lithuania
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-611
+ Thailand
+
+
+ 0000000-9999999
+ 0
+
+
+
+
+ 978-612
+ Peru
+
+
+ 0000000-2999999
+ 2
+
+
+ 3000000-3999999
+ 3
+
+
+ 4000000-4499999
+ 4
+
+
+ 4500000-4999999
+ 5
+
+
+ 5000000-5149999
+ 4
+
+
+ 5150000-9999999
+ 0
+
+
+
+
+ 978-613
+ Mauritius
+
+
+ 0000000-9999999
+ 1
+
+
+
+
+ 978-614
+ Lebanon
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-615
+ Hungary
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-4999999
+ 3
+
+
+ 5000000-7999999
+ 4
+
+
+ 8000000-8999999
+ 5
+
+
+ 9000000-9999999
+ 0
+
+
+
+
+ 978-616
+ Thailand
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8999999
+ 4
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-617
+ Ukraine
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-6999999
+ 3
+
+
+ 7000000-8999999
+ 4
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-618
+ Greece
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-4999999
+ 3
+
+
+ 5000000-7999999
+ 4
+
+
+ 8000000-9999999
+ 5
+
+
+
+
+ 978-619
+ Bulgaria
+
+
+ 0000000-1499999
+ 2
+
+
+ 1500000-6999999
+ 3
+
+
+ 7000000-8999999
+ 4
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-620
+ Mauritius
+
+
+ 0000000-9999999
+ 1
+
+
+
+
+ 978-621
+ Philippines
+
+
+ 0000000-2999999
+ 2
+
+
+ 3000000-3999999
+ 0
+
+
+ 4000000-5999999
+ 3
+
+
+ 6000000-7999999
+ 0
+
+
+ 8000000-8999999
+ 4
+
+
+ 9000000-9499999
+ 0
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-622
+ Iran
+
+
+ 0000000-1099999
+ 2
+
+
+ 1100000-1999999
+ 0
+
+
+ 2000000-4249999
+ 3
+
+
+ 4250000-5199999
+ 0
+
+
+ 5200000-8499999
+ 4
+
+
+ 8500000-8999999
+ 0
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-623
+ Indonesia
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-1299999
+ 0
+
+
+ 1300000-4999999
+ 3
+
+
+ 5000000-5249999
+ 0
+
+
+ 5250000-8799999
+ 4
+
+
+ 8800000-9999999
+ 5
+
+
+
+
+ 978-624
+ Sri Lanka
+
+
+ 0000000-0499999
+ 2
+
+
+ 0500000-1999999
+ 0
+
+
+ 2000000-2499999
+ 3
+
+
+ 2500000-4999999
+ 0
+
+
+ 5000000-6449999
+ 4
+
+
+ 6450000-9449999
+ 0
+
+
+ 9450000-9999999
+ 5
+
+
+
+
+ 978-625
+ Turkey
+
+
+ 0000000-0099999
+ 2
+
+
+ 0100000-3649999
+ 0
+
+
+ 3650000-4429999
+ 3
+
+
+ 4430000-4449999
+ 5
+
+
+ 4450000-4499999
+ 3
+
+
+ 4500000-6349999
+ 0
+
+
+ 6350000-7793999
+ 4
+
+
+ 7794000-7794999
+ 5
+
+
+ 7795000-8499999
+ 4
+
+
+ 8500000-9899999
+ 0
+
+
+ 9900000-9999999
+ 5
+
+
+
+
+ 978-626
+ Taiwan
+
+
+ 0000000-0499999
+ 2
+
+
+ 0500000-2999999
+ 0
+
+
+ 3000000-4999999
+ 3
+
+
+ 5000000-6999999
+ 0
+
+
+ 7000000-7999999
+ 4
+
+
+ 8000000-9499999
+ 0
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-627
+ Pakistan
+
+
+ 0000000-2999999
+ 0
+
+
+ 3000000-3199999
+ 2
+
+
+ 3200000-4999999
+ 0
+
+
+ 5000000-5249999
+ 3
+
+
+ 5250000-7499999
+ 0
+
+
+ 7500000-7999999
+ 4
+
+
+ 8000000-9999999
+ 0
+
+
+
+
+ 978-628
+ Colombia
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-5499999
+ 3
+
+
+ 5500000-7499999
+ 0
+
+
+ 7500000-8499999
+ 4
+
+
+ 8500000-9499999
+ 0
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-629
+ Malaysia
+
+
+ 0000000-0299999
+ 2
+
+
+ 0300000-4699999
+ 0
+
+
+ 4700000-4999999
+ 3
+
+
+ 5000000-7499999
+ 0
+
+
+ 7500000-7999999
+ 4
+
+
+ 8000000-9649999
+ 0
+
+
+ 9650000-9999999
+ 5
+
+
+
+
+ 978-630
+ Romania
+
+
+ 0000000-2999999
+ 0
+
+
+ 3000000-3499999
+ 3
+
+
+ 3500000-6499999
+ 0
+
+
+ 6500000-6849999
+ 4
+
+
+ 6850000-9999999
+ 0
+
+
+
+
+ 978-631
+ Argentina
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-2999999
+ 0
+
+
+ 3000000-3999999
+ 3
+
+
+ 4000000-6499999
+ 0
+
+
+ 6500000-7499999
+ 4
+
+
+ 7500000-8999999
+ 0
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-65
+ Brazil
+
+
+ 0000000-0199999
+ 2
+
+
+ 0200000-2499999
+ 0
+
+
+ 2500000-2999999
+ 3
+
+
+ 3000000-3029999
+ 3
+
+
+ 3030000-4999999
+ 0
+
+
+ 5000000-5129999
+ 4
+
+
+ 5130000-5349999
+ 0
+
+
+ 5350000-6149999
+ 4
+
+
+ 6150000-7999999
+ 0
+
+
+ 8000000-8182499
+ 5
+
+
+ 8182500-8449999
+ 0
+
+
+ 8450000-8999999
+ 5
+
+
+ 9000000-9024499
+ 6
+
+
+ 9024500-9799999
+ 0
+
+
+ 9800000-9999999
+ 6
+
+
+
+
+ 978-7
+ China, People's Republic
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-4999999
+ 3
+
+
+ 5000000-7999999
+ 4
+
+
+ 8000000-8999999
+ 5
+
+
+ 9000000-9999999
+ 6
+
+
+
+
+ 978-80
+ former Czechoslovakia
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-5299999
+ 3
+
+
+ 5300000-5499999
+ 5
+
+
+ 5500000-6899999
+ 3
+
+
+ 6900000-6999999
+ 5
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9989999
+ 6
+
+
+ 9990000-9999999
+ 5
+
+
+
+
+ 978-81
+ India
+
+
+ 0000000-1899999
+ 2
+
+
+ 1900000-1999999
+ 5
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9999999
+ 6
+
+
+
+
+ 978-82
+ Norway
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6899999
+ 3
+
+
+ 6900000-6999999
+ 6
+
+
+ 7000000-8999999
+ 4
+
+
+ 9000000-9899999
+ 5
+
+
+ 9900000-9999999
+ 6
+
+
+
+
+ 978-83
+ Poland
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-5999999
+ 3
+
+
+ 6000000-6999999
+ 5
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9999999
+ 6
+
+
+
+
+ 978-84
+ Spain
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-1049999
+ 5
+
+
+ 1050000-1199999
+ 4
+
+
+ 1200000-1299999
+ 6
+
+
+ 1300000-1399999
+ 4
+
+
+ 1400000-1499999
+ 3
+
+
+ 1500000-1999999
+ 5
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9199999
+ 4
+
+
+ 9200000-9239999
+ 6
+
+
+ 9240000-9299999
+ 5
+
+
+ 9300000-9499999
+ 6
+
+
+ 9500000-9699999
+ 5
+
+
+ 9700000-9999999
+ 4
+
+
+
+
+ 978-85
+ Brazil
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-4549999
+ 3
+
+
+ 4550000-4552999
+ 6
+
+
+ 4553000-4559999
+ 5
+
+
+ 4560000-5289999
+ 3
+
+
+ 5290000-5319999
+ 5
+
+
+ 5320000-5339999
+ 4
+
+
+ 5340000-5399999
+ 3
+
+
+ 5400000-5402999
+ 5
+
+
+ 5403000-5403999
+ 5
+
+
+ 5404000-5404999
+ 6
+
+
+ 5405000-5408999
+ 5
+
+
+ 5409000-5409999
+ 6
+
+
+ 5410000-5439999
+ 5
+
+
+ 5440000-5479999
+ 4
+
+
+ 5480000-5499999
+ 5
+
+
+ 5500000-5999999
+ 4
+
+
+ 6000000-6999999
+ 5
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9249999
+ 6
+
+
+ 9250000-9449999
+ 5
+
+
+ 9450000-9599999
+ 4
+
+
+ 9600000-9799999
+ 2
+
+
+ 9800000-9999999
+ 5
+
+
+
+
+ 978-86
+ former Yugoslavia
+
+
+ 0000000-2999999
+ 2
+
+
+ 3000000-5999999
+ 3
+
+
+ 6000000-7999999
+ 4
+
+
+ 8000000-8999999
+ 5
+
+
+ 9000000-9999999
+ 6
+
+
+
+
+ 978-87
+ Denmark
+
+
+ 0000000-2999999
+ 2
+
+
+ 3000000-3999999
+ 0
+
+
+ 4000000-6499999
+ 3
+
+
+ 6500000-6999999
+ 0
+
+
+ 7000000-7999999
+ 4
+
+
+ 8000000-8499999
+ 0
+
+
+ 8500000-9499999
+ 5
+
+
+ 9500000-9699999
+ 0
+
+
+ 9700000-9999999
+ 6
+
+
+
+
+ 978-88
+ Italy
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-3119999
+ 3
+
+
+ 3120000-3149999
+ 5
+
+
+ 3150000-3189999
+ 3
+
+
+ 3190000-3229999
+ 5
+
+
+ 3230000-3269999
+ 3
+
+
+ 3270000-3389999
+ 4
+
+
+ 3390000-3609999
+ 3
+
+
+ 3610000-3629999
+ 4
+
+
+ 3630000-5489999
+ 3
+
+
+ 5490000-5549999
+ 4
+
+
+ 5550000-5999999
+ 3
+
+
+ 6000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9099999
+ 6
+
+
+ 9100000-9269999
+ 3
+
+
+ 9270000-9399999
+ 4
+
+
+ 9400000-9479999
+ 6
+
+
+ 9480000-9999999
+ 5
+
+
+
+
+ 978-89
+ Korea, Republic
+
+
+ 0000000-2499999
+ 2
+
+
+ 2500000-5499999
+ 3
+
+
+ 5500000-8499999
+ 4
+
+
+ 8500000-9499999
+ 5
+
+
+ 9500000-9699999
+ 6
+
+
+ 9700000-9899999
+ 5
+
+
+ 9900000-9999999
+ 3
+
+
+
+
+ 978-90
+ Netherlands
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-4999999
+ 3
+
+
+ 5000000-6999999
+ 4
+
+
+ 7000000-7999999
+ 5
+
+
+ 8000000-8499999
+ 6
+
+
+ 8500000-8999999
+ 4
+
+
+ 9000000-9099999
+ 2
+
+
+ 9100000-9399999
+ 0
+
+
+ 9400000-9499999
+ 2
+
+
+ 9500000-9999999
+ 0
+
+
+
+
+ 978-91
+ Sweden
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-4999999
+ 2
+
+
+ 5000000-6499999
+ 3
+
+
+ 6500000-6999999
+ 0
+
+
+ 7000000-8199999
+ 4
+
+
+ 8200000-8499999
+ 0
+
+
+ 8500000-9499999
+ 5
+
+
+ 9500000-9699999
+ 0
+
+
+ 9700000-9999999
+ 6
+
+
+
+
+ 978-92
+ International NGO Publishers and EU Organizations
+
+
+ 0000000-5999999
+ 1
+
+
+ 6000000-7999999
+ 2
+
+
+ 8000000-8999999
+ 3
+
+
+ 9000000-9499999
+ 4
+
+
+ 9500000-9899999
+ 5
+
+
+ 9900000-9999999
+ 6
+
+
+
+
+ 978-93
+ India
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-4999999
+ 3
+
+
+ 5000000-7999999
+ 4
+
+
+ 8000000-9599999
+ 5
+
+
+ 9600000-9999999
+ 6
+
+
+
+
+ 978-94
+ Netherlands
+
+
+ 0000000-5999999
+ 3
+
+
+ 6000000-8999999
+ 4
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-950
+ Argentina
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-8999999
+ 3
+
+
+ 9000000-9899999
+ 4
+
+
+ 9900000-9999999
+ 5
+
+
+
+
+ 978-951
+ Finland
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-5499999
+ 2
+
+
+ 5500000-8899999
+ 3
+
+
+ 8900000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-952
+ Finland
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-4999999
+ 3
+
+
+ 5000000-5999999
+ 4
+
+
+ 6000000-6499999
+ 2
+
+
+ 6500000-6599999
+ 5
+
+
+ 6600000-6699999
+ 4
+
+
+ 6700000-6999999
+ 5
+
+
+ 7000000-7999999
+ 4
+
+
+ 8000000-9499999
+ 2
+
+
+ 9500000-9899999
+ 4
+
+
+ 9900000-9999999
+ 5
+
+
+
+
+ 978-953
+ Croatia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1499999
+ 2
+
+
+ 1500000-4799999
+ 3
+
+
+ 4800000-4999999
+ 5
+
+
+ 5000000-5009999
+ 3
+
+
+ 5010000-5099999
+ 5
+
+
+ 5100000-5499999
+ 2
+
+
+ 5500000-5999999
+ 5
+
+
+ 6000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-954
+ Bulgaria
+
+
+ 0000000-2899999
+ 2
+
+
+ 2900000-2999999
+ 4
+
+
+ 3000000-7999999
+ 3
+
+
+ 8000000-8999999
+ 4
+
+
+ 9000000-9299999
+ 5
+
+
+ 9300000-9999999
+ 4
+
+
+
+
+ 978-955
+ Sri Lanka
+
+
+ 0000000-1999999
+ 4
+
+
+ 2000000-3399999
+ 2
+
+
+ 3400000-3549999
+ 4
+
+
+ 3550000-3599999
+ 5
+
+
+ 3600000-3799999
+ 4
+
+
+ 3800000-3899999
+ 5
+
+
+ 3900000-4099999
+ 4
+
+
+ 4100000-4499999
+ 5
+
+
+ 4500000-4999999
+ 4
+
+
+ 5000000-5499999
+ 5
+
+
+ 5500000-7109999
+ 3
+
+
+ 7110000-7149999
+ 5
+
+
+ 7150000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-956
+ Chile
+
+
+ 0000000-0899999
+ 2
+
+
+ 0900000-0999999
+ 5
+
+
+ 1000000-1999999
+ 2
+
+
+ 2000000-5999999
+ 3
+
+
+ 6000000-6999999
+ 4
+
+
+ 7000000-9999999
+ 4
+
+
+
+
+ 978-957
+ Taiwan
+
+
+ 0000000-0299999
+ 2
+
+
+ 0300000-0499999
+ 4
+
+
+ 0500000-1999999
+ 2
+
+
+ 2000000-2099999
+ 4
+
+
+ 2100000-2799999
+ 2
+
+
+ 2800000-3099999
+ 5
+
+
+ 3100000-4399999
+ 2
+
+
+ 4400000-8199999
+ 3
+
+
+ 8200000-9699999
+ 4
+
+
+ 9700000-9999999
+ 5
+
+
+
+
+ 978-958
+ Colombia
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-5099999
+ 3
+
+
+ 5100000-5199999
+ 4
+
+
+ 5200000-5399999
+ 5
+
+
+ 5400000-5599999
+ 4
+
+
+ 5600000-5999999
+ 5
+
+
+ 6000000-7999999
+ 3
+
+
+ 8000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-959
+ Cuba
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-9999999
+ 5
+
+
+
+
+ 978-960
+ Greece
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6599999
+ 3
+
+
+ 6600000-6899999
+ 4
+
+
+ 6900000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-9299999
+ 5
+
+
+ 9300000-9399999
+ 2
+
+
+ 9400000-9799999
+ 4
+
+
+ 9800000-9999999
+ 5
+
+
+
+
+ 978-961
+ Slovenia
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-5999999
+ 3
+
+
+ 6000000-8999999
+ 4
+
+
+ 9000000-9799999
+ 5
+
+
+ 9800000-9999999
+ 0
+
+
+
+
+ 978-962
+ Hong Kong, China
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8699999
+ 5
+
+
+ 8700000-8999999
+ 4
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-963
+ Hungary
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-964
+ Iran
+
+
+ 0000000-1499999
+ 2
+
+
+ 1500000-2499999
+ 3
+
+
+ 2500000-2999999
+ 4
+
+
+ 3000000-5499999
+ 3
+
+
+ 5500000-8999999
+ 4
+
+
+ 9000000-9699999
+ 5
+
+
+ 9700000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-965
+ Israel
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-5999999
+ 3
+
+
+ 6000000-6999999
+ 0
+
+
+ 7000000-7999999
+ 4
+
+
+ 8000000-8999999
+ 0
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-966
+ Ukraine
+
+
+ 0000000-1299999
+ 2
+
+
+ 1300000-1399999
+ 3
+
+
+ 1400000-1499999
+ 2
+
+
+ 1500000-1699999
+ 4
+
+
+ 1700000-1999999
+ 3
+
+
+ 2000000-2789999
+ 4
+
+
+ 2790000-2899999
+ 3
+
+
+ 2900000-2999999
+ 4
+
+
+ 3000000-6999999
+ 3
+
+
+ 7000000-8999999
+ 4
+
+
+ 9000000-9099999
+ 5
+
+
+ 9100000-9499999
+ 3
+
+
+ 9500000-9799999
+ 5
+
+
+ 9800000-9999999
+ 3
+
+
+
+
+ 978-967
+ Malaysia
+
+
+ 0000000-0999999
+ 4
+
+
+ 1000000-1999999
+ 5
+
+
+ 2000000-2499999
+ 4
+
+
+ 2500000-2549999
+ 3
+
+
+ 2550000-2699999
+ 5
+
+
+ 2700000-2799999
+ 4
+
+
+ 2800000-2999999
+ 4
+
+
+ 3000000-4999999
+ 3
+
+
+ 5000000-5999999
+ 4
+
+
+ 6000000-8999999
+ 2
+
+
+ 9000000-9899999
+ 3
+
+
+ 9900000-9989999
+ 4
+
+
+ 9990000-9999999
+ 5
+
+
+
+
+ 978-968
+ Mexico
+
+
+ 0100000-3999999
+ 2
+
+
+ 4000000-4999999
+ 3
+
+
+ 5000000-7999999
+ 4
+
+
+ 8000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-969
+ Pakistan
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-2099999
+ 2
+
+
+ 2100000-2199999
+ 3
+
+
+ 2200000-2299999
+ 4
+
+
+ 2300000-2399999
+ 5
+
+
+ 2400000-3999999
+ 2
+
+
+ 4000000-7499999
+ 3
+
+
+ 7500000-9999999
+ 4
+
+
+
+
+ 978-970
+ Mexico
+
+
+ 0100000-5999999
+ 2
+
+
+ 6000000-8999999
+ 3
+
+
+ 9000000-9099999
+ 4
+
+
+ 9100000-9699999
+ 5
+
+
+ 9700000-9999999
+ 4
+
+
+
+
+ 978-971
+ Philippines
+
+
+ 0000000-0159999
+ 3
+
+
+ 0160000-0199999
+ 4
+
+
+ 0200000-0299999
+ 2
+
+
+ 0300000-0599999
+ 4
+
+
+ 0600000-4999999
+ 2
+
+
+ 5000000-8499999
+ 3
+
+
+ 8500000-9099999
+ 4
+
+
+ 9100000-9599999
+ 5
+
+
+ 9600000-9699999
+ 4
+
+
+ 9700000-9899999
+ 2
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-972
+ Portugal
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-5499999
+ 2
+
+
+ 5500000-7999999
+ 3
+
+
+ 8000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-973
+ Romania
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1699999
+ 3
+
+
+ 1700000-1999999
+ 4
+
+
+ 2000000-5499999
+ 2
+
+
+ 5500000-7599999
+ 3
+
+
+ 7600000-8499999
+ 4
+
+
+ 8500000-8899999
+ 5
+
+
+ 8900000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-974
+ Thailand
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8499999
+ 4
+
+
+ 8500000-8999999
+ 5
+
+
+ 9000000-9499999
+ 5
+
+
+ 9500000-9999999
+ 4
+
+
+
+
+ 978-975
+ Turkey
+
+
+ 0000000-0199999
+ 5
+
+
+ 0200000-2399999
+ 2
+
+
+ 2400000-2499999
+ 4
+
+
+ 2500000-5999999
+ 3
+
+
+ 6000000-9199999
+ 4
+
+
+ 9200000-9899999
+ 5
+
+
+ 9900000-9999999
+ 3
+
+
+
+
+ 978-976
+ Caribbean Community
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-5999999
+ 2
+
+
+ 6000000-7999999
+ 3
+
+
+ 8000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-977
+ Egypt
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-4999999
+ 3
+
+
+ 5000000-6999999
+ 4
+
+
+ 7000000-8499999
+ 3
+
+
+ 8500000-8929999
+ 5
+
+
+ 8930000-8949999
+ 3
+
+
+ 8950000-8999999
+ 4
+
+
+ 9000000-9899999
+ 2
+
+
+ 9900000-9999999
+ 3
+
+
+
+
+ 978-978
+ Nigeria
+
+
+ 0000000-1999999
+ 3
+
+
+ 2000000-2999999
+ 4
+
+
+ 3000000-7799999
+ 5
+
+
+ 7800000-7999999
+ 3
+
+
+ 8000000-8999999
+ 4
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-979
+ Indonesia
+
+
+ 0000000-0999999
+ 3
+
+
+ 1000000-1499999
+ 4
+
+
+ 1500000-1999999
+ 5
+
+
+ 2000000-2999999
+ 2
+
+
+ 3000000-3999999
+ 4
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-980
+ Venezuela
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-5999999
+ 3
+
+
+ 6000000-9999999
+ 4
+
+
+
+
+ 978-981
+ Singapore
+
+
+ 0000000-1699999
+ 2
+
+
+ 1700000-1799999
+ 5
+
+
+ 1800000-1999999
+ 2
+
+
+ 2000000-2999999
+ 3
+
+
+ 3000000-3099999
+ 4
+
+
+ 3100000-3999999
+ 3
+
+
+ 4000000-9499999
+ 4
+
+
+ 9500000-9899999
+ 0
+
+
+ 9900000-9999999
+ 2
+
+
+
+
+ 978-982
+ South Pacific
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-6999999
+ 3
+
+
+ 7000000-8999999
+ 2
+
+
+ 9000000-9799999
+ 4
+
+
+ 9800000-9999999
+ 5
+
+
+
+
+ 978-983
+ Malaysia
+
+
+ 0000000-0199999
+ 2
+
+
+ 0200000-1999999
+ 3
+
+
+ 2000000-3999999
+ 4
+
+
+ 4000000-4499999
+ 5
+
+
+ 4500000-4999999
+ 2
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-8999999
+ 3
+
+
+ 9000000-9899999
+ 4
+
+
+ 9900000-9999999
+ 5
+
+
+
+
+ 978-984
+ Bangladesh
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-8999999
+ 4
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-985
+ Belarus
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-5999999
+ 3
+
+
+ 6000000-8799999
+ 4
+
+
+ 8800000-8999999
+ 3
+
+
+ 9000000-9999999
+ 5
+
+
+
+
+ 978-986
+ Taiwan
+
+
+ 0000000-0599999
+ 2
+
+
+ 0600000-0699999
+ 5
+
+
+ 0700000-0799999
+ 4
+
+
+ 0800000-1199999
+ 2
+
+
+ 1200000-5399999
+ 3
+
+
+ 5400000-7999999
+ 4
+
+
+ 8000000-9999999
+ 5
+
+
+
+
+ 978-987
+ Argentina
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-1999999
+ 4
+
+
+ 2000000-2999999
+ 5
+
+
+ 3000000-3599999
+ 2
+
+
+ 3600000-4199999
+ 4
+
+
+ 4200000-4399999
+ 2
+
+
+ 4400000-4499999
+ 4
+
+
+ 4500000-4899999
+ 5
+
+
+ 4900000-4999999
+ 4
+
+
+ 5000000-8249999
+ 3
+
+
+ 8250000-8279999
+ 4
+
+
+ 8280000-8299999
+ 5
+
+
+ 8300000-8499999
+ 4
+
+
+ 8500000-8899999
+ 2
+
+
+ 8900000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-988
+ Hong Kong, China
+
+
+ 0000000-1199999
+ 2
+
+
+ 1200000-1999999
+ 5
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-7999999
+ 5
+
+
+ 8000000-9699999
+ 4
+
+
+ 9700000-9999999
+ 5
+
+
+
+
+ 978-989
+ Portugal
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3499999
+ 2
+
+
+ 3500000-3699999
+ 5
+
+
+ 3700000-5299999
+ 2
+
+
+ 5300000-5499999
+ 5
+
+
+ 5500000-7999999
+ 3
+
+
+ 8000000-9499999
+ 4
+
+
+ 9500000-9999999
+ 5
+
+
+
+
+ 978-9910
+ Uzbekistan
+
+
+ 0000000-7299999
+ 0
+
+
+ 7300000-7499999
+ 3
+
+
+ 7500000-9649999
+ 0
+
+
+ 9650000-9999999
+ 4
+
+
+
+
+ 978-9911
+ Montenegro
+
+
+ 0000000-1999999
+ 0
+
+
+ 2000000-2499999
+ 2
+
+
+ 2500000-5499999
+ 0
+
+
+ 5500000-7499999
+ 3
+
+
+ 7500000-9999999
+ 0
+
+
+
+
+ 978-9912
+ Tanzania
+
+
+ 0000000-3999999
+ 0
+
+
+ 4000000-4499999
+ 2
+
+
+ 4500000-7499999
+ 0
+
+
+ 7500000-7999999
+ 3
+
+
+ 8000000-9799999
+ 0
+
+
+ 9800000-9999999
+ 4
+
+
+
+
+ 978-9913
+ Uganda
+
+
+ 0000000-0799999
+ 2
+
+
+ 0800000-5999999
+ 0
+
+
+ 6000000-6999999
+ 3
+
+
+ 7000000-9549999
+ 0
+
+
+ 9550000-9999999
+ 4
+
+
+
+
+ 978-9914
+ Kenya
+
+
+ 0000000-3999999
+ 0
+
+
+ 4000000-5299999
+ 2
+
+
+ 5300000-6999999
+ 0
+
+
+ 7000000-7749999
+ 3
+
+
+ 7750000-9599999
+ 0
+
+
+ 9600000-9999999
+ 4
+
+
+
+
+ 978-9915
+ Uruguay
+
+
+ 0000000-3999999
+ 0
+
+
+ 4000000-5999999
+ 2
+
+
+ 6000000-6499999
+ 0
+
+
+ 6500000-7999999
+ 3
+
+
+ 8000000-9299999
+ 0
+
+
+ 9300000-9999999
+ 4
+
+
+
+
+ 978-9916
+ Estonia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-3999999
+ 2
+
+
+ 4000000-5999999
+ 1
+
+
+ 6000000-7999999
+ 3
+
+
+ 8000000-8499999
+ 2
+
+
+ 8500000-8999999
+ 3
+
+
+ 9000000-9249999
+ 0
+
+
+ 9250000-9999999
+ 4
+
+
+
+
+ 978-9917
+ Bolivia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-2999999
+ 0
+
+
+ 3000000-3499999
+ 2
+
+
+ 3500000-5999999
+ 0
+
+
+ 6000000-6999999
+ 3
+
+
+ 7000000-9799999
+ 0
+
+
+ 9800000-9999999
+ 4
+
+
+
+
+ 978-9918
+ Malta
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1999999
+ 0
+
+
+ 2000000-2999999
+ 2
+
+
+ 3000000-5999999
+ 0
+
+
+ 6000000-7999999
+ 3
+
+
+ 8000000-9499999
+ 0
+
+
+ 9500000-9999999
+ 4
+
+
+
+
+ 978-9919
+ Mongolia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1999999
+ 0
+
+
+ 2000000-2999999
+ 2
+
+
+ 3000000-4999999
+ 0
+
+
+ 5000000-5999999
+ 3
+
+
+ 6000000-8999999
+ 0
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9920
+ Morocco
+
+
+ 0000000-2999999
+ 0
+
+
+ 3000000-4299999
+ 2
+
+
+ 4300000-4999999
+ 0
+
+
+ 5000000-7999999
+ 3
+
+
+ 8000000-8749999
+ 0
+
+
+ 8750000-9999999
+ 4
+
+
+
+
+ 978-9921
+ Kuwait
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-2999999
+ 0
+
+
+ 3000000-3999999
+ 2
+
+
+ 4000000-6999999
+ 0
+
+
+ 7000000-8999999
+ 3
+
+
+ 9000000-9699999
+ 0
+
+
+ 9700000-9999999
+ 4
+
+
+
+
+ 978-9922
+ Iraq
+
+
+ 0000000-1999999
+ 0
+
+
+ 2000000-2999999
+ 2
+
+
+ 3000000-5999999
+ 0
+
+
+ 6000000-7999999
+ 3
+
+
+ 8000000-8499999
+ 0
+
+
+ 8500000-9999999
+ 4
+
+
+
+
+ 978-9923
+ Jordan
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-5999999
+ 2
+
+
+ 6000000-6999999
+ 0
+
+
+ 7000000-8999999
+ 3
+
+
+ 9000000-9399999
+ 0
+
+
+ 9400000-9999999
+ 4
+
+
+
+
+ 978-9924
+ Cambodia
+
+
+ 0000000-2999999
+ 0
+
+
+ 3000000-3999999
+ 2
+
+
+ 4000000-4999999
+ 0
+
+
+ 5000000-6499999
+ 3
+
+
+ 6500000-8999999
+ 0
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9925
+ Cyprus
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5499999
+ 2
+
+
+ 5500000-7349999
+ 3
+
+
+ 7350000-9999999
+ 4
+
+
+
+
+ 978-9926
+ Bosnia and Herzegovina
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-9999999
+ 4
+
+
+
+
+ 978-9927
+ Qatar
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-3999999
+ 3
+
+
+ 4000000-4999999
+ 4
+
+
+ 5000000-9999999
+ 0
+
+
+
+
+ 978-9928
+ Albania
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-3999999
+ 3
+
+
+ 4000000-4999999
+ 4
+
+
+ 5000000-7999999
+ 0
+
+
+ 8000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 2
+
+
+
+
+ 978-9929
+ Guatemala
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-5499999
+ 2
+
+
+ 5500000-7999999
+ 3
+
+
+ 8000000-9999999
+ 4
+
+
+
+
+ 978-9930
+ Costa Rica
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-9399999
+ 3
+
+
+ 9400000-9999999
+ 4
+
+
+
+
+ 978-9931
+ Algeria
+
+
+ 0000000-2399999
+ 2
+
+
+ 2400000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9932
+ Lao People's Democratic Republic
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-8499999
+ 3
+
+
+ 8500000-9999999
+ 4
+
+
+
+
+ 978-9933
+ Syria
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9934
+ Latvia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 2
+
+
+ 5000000-7999999
+ 3
+
+
+ 8000000-9999999
+ 4
+
+
+
+
+ 978-9935
+ Iceland
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9936
+ Afghanistan
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-9999999
+ 4
+
+
+
+
+ 978-9937
+ Nepal
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-4999999
+ 2
+
+
+ 5000000-7999999
+ 3
+
+
+ 8000000-9999999
+ 4
+
+
+
+
+ 978-9938
+ Tunisia
+
+
+ 0000000-7999999
+ 2
+
+
+ 8000000-9499999
+ 3
+
+
+ 9500000-9749999
+ 4
+
+
+ 9750000-9909999
+ 3
+
+
+ 9910000-9999999
+ 4
+
+
+
+
+ 978-9939
+ Armenia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-8999999
+ 3
+
+
+ 9000000-9599999
+ 4
+
+
+ 9600000-9799999
+ 3
+
+
+ 9800000-9999999
+ 2
+
+
+
+
+ 978-9940
+ Montenegro
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-4999999
+ 2
+
+
+ 5000000-8399999
+ 3
+
+
+ 8400000-8699999
+ 2
+
+
+ 8700000-9999999
+ 4
+
+
+
+
+ 978-9941
+ Georgia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-8999999
+ 1
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9942
+ Ecuador
+
+
+ 0000000-5999999
+ 2
+
+
+ 6000000-6999999
+ 3
+
+
+ 7000000-7499999
+ 4
+
+
+ 7500000-8499999
+ 3
+
+
+ 8500000-8999999
+ 4
+
+
+ 9000000-9849999
+ 3
+
+
+ 9850000-9999999
+ 4
+
+
+
+
+ 978-9943
+ Uzbekistan
+
+
+ 0000000-2999999
+ 2
+
+
+ 3000000-3999999
+ 3
+
+
+ 4000000-9749999
+ 4
+
+
+ 9750000-9999999
+ 3
+
+
+
+
+ 978-9944
+ Turkey
+
+
+ 0000000-0999999
+ 4
+
+
+ 1000000-4999999
+ 3
+
+
+ 5000000-5999999
+ 4
+
+
+ 6000000-6999999
+ 2
+
+
+ 7000000-7999999
+ 3
+
+
+ 8000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-9945
+ Dominican Republic
+
+
+ 0000000-0099999
+ 2
+
+
+ 0100000-0799999
+ 3
+
+
+ 0800000-3999999
+ 2
+
+
+ 4000000-5699999
+ 3
+
+
+ 5700000-5799999
+ 2
+
+
+ 5800000-7999999
+ 3
+
+
+ 8000000-8099999
+ 2
+
+
+ 8100000-8499999
+ 3
+
+
+ 8500000-9999999
+ 4
+
+
+
+
+ 978-9946
+ Korea, P.D.R.
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9947
+ Algeria
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-9948
+ United Arab Emirates
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-8499999
+ 3
+
+
+ 8500000-9999999
+ 4
+
+
+
+
+ 978-9949
+ Estonia
+
+
+ 0000000-0899999
+ 2
+
+
+ 0900000-0999999
+ 3
+
+
+ 1000000-3999999
+ 2
+
+
+ 4000000-6999999
+ 3
+
+
+ 7000000-7199999
+ 2
+
+
+ 7200000-7499999
+ 4
+
+
+ 7500000-8999999
+ 2
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9950
+ Palestine
+
+
+ 0000000-2999999
+ 2
+
+
+ 3000000-8499999
+ 3
+
+
+ 8500000-9999999
+ 4
+
+
+
+
+ 978-9951
+ Kosova
+
+
+ 0000000-3899999
+ 2
+
+
+ 3900000-8499999
+ 3
+
+
+ 8500000-9799999
+ 4
+
+
+ 9800000-9999999
+ 3
+
+
+
+
+ 978-9952
+ Azerbaijan
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-9999999
+ 4
+
+
+
+
+ 978-9953
+ Lebanon
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-3999999
+ 2
+
+
+ 4000000-5999999
+ 3
+
+
+ 6000000-8999999
+ 2
+
+
+ 9000000-9299999
+ 4
+
+
+ 9300000-9699999
+ 2
+
+
+ 9700000-9999999
+ 3
+
+
+
+
+ 978-9954
+ Morocco
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3999999
+ 2
+
+
+ 4000000-7999999
+ 3
+
+
+ 8000000-9899999
+ 4
+
+
+ 9900000-9999999
+ 2
+
+
+
+
+ 978-9955
+ Lithuania
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-9299999
+ 3
+
+
+ 9300000-9999999
+ 4
+
+
+
+
+ 978-9956
+ Cameroon
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9957
+ Jordan
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-6499999
+ 3
+
+
+ 6500000-6799999
+ 2
+
+
+ 6800000-6999999
+ 3
+
+
+ 7000000-8499999
+ 2
+
+
+ 8500000-8799999
+ 4
+
+
+ 8800000-9999999
+ 2
+
+
+
+
+ 978-9958
+ Bosnia and Herzegovina
+
+
+ 0000000-0199999
+ 2
+
+
+ 0200000-0299999
+ 3
+
+
+ 0300000-0399999
+ 4
+
+
+ 0400000-0899999
+ 3
+
+
+ 0900000-0999999
+ 4
+
+
+ 1000000-1899999
+ 2
+
+
+ 1900000-1999999
+ 4
+
+
+ 2000000-4999999
+ 2
+
+
+ 5000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9959
+ Libya
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9499999
+ 3
+
+
+ 9500000-9699999
+ 4
+
+
+ 9700000-9799999
+ 3
+
+
+ 9800000-9999999
+ 2
+
+
+
+
+ 978-9960
+ Saudi Arabia
+
+
+ 0000000-5999999
+ 2
+
+
+ 6000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9961
+ Algeria
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-6999999
+ 2
+
+
+ 7000000-9499999
+ 3
+
+
+ 9500000-9999999
+ 4
+
+
+
+
+ 978-9962
+ Panama
+
+
+ 0000000-5499999
+ 2
+
+
+ 5500000-5599999
+ 4
+
+
+ 5600000-5999999
+ 2
+
+
+ 6000000-8499999
+ 3
+
+
+ 8500000-9999999
+ 4
+
+
+
+
+ 978-9963
+ Cyprus
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-2499999
+ 4
+
+
+ 2500000-2799999
+ 3
+
+
+ 2800000-2999999
+ 4
+
+
+ 3000000-5499999
+ 2
+
+
+ 5500000-7349999
+ 3
+
+
+ 7350000-7499999
+ 4
+
+
+ 7500000-9999999
+ 4
+
+
+
+
+ 978-9964
+ Ghana
+
+
+ 0000000-6999999
+ 1
+
+
+ 7000000-9499999
+ 2
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-9965
+ Kazakhstan
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9966
+ Kenya
+
+
+ 0000000-1399999
+ 3
+
+
+ 1400000-1499999
+ 2
+
+
+ 1500000-1999999
+ 4
+
+
+ 2000000-6999999
+ 2
+
+
+ 7000000-7499999
+ 4
+
+
+ 7500000-8209999
+ 3
+
+
+ 8210000-8249999
+ 4
+
+
+ 8250000-8259999
+ 3
+
+
+ 8260000-8289999
+ 4
+
+
+ 8290000-9599999
+ 3
+
+
+ 9600000-9999999
+ 4
+
+
+
+
+ 978-9967
+ Kyrgyz Republic
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9968
+ Costa Rica
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-9399999
+ 3
+
+
+ 9400000-9999999
+ 4
+
+
+
+
+ 978-9969
+ Algeria
+
+
+ 0000000-0699999
+ 2
+
+
+ 0700000-4999999
+ 0
+
+
+ 5000000-6499999
+ 3
+
+
+ 6500000-9699999
+ 0
+
+
+ 9700000-9999999
+ 4
+
+
+
+
+ 978-9970
+ Uganda
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9971
+ Singapore
+
+
+ 0000000-5999999
+ 1
+
+
+ 6000000-8999999
+ 2
+
+
+ 9000000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-9972
+ Peru
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-1999999
+ 1
+
+
+ 2000000-2499999
+ 3
+
+
+ 2500000-2999999
+ 4
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9973
+ Tunisia
+
+
+ 0000000-0599999
+ 2
+
+
+ 0600000-0899999
+ 3
+
+
+ 0900000-0999999
+ 4
+
+
+ 1000000-6999999
+ 2
+
+
+ 7000000-9699999
+ 3
+
+
+ 9700000-9999999
+ 4
+
+
+
+
+ 978-9974
+ Uruguay
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5499999
+ 2
+
+
+ 5500000-7499999
+ 3
+
+
+ 7500000-8799999
+ 4
+
+
+ 8800000-9099999
+ 3
+
+
+ 9100000-9499999
+ 2
+
+
+ 9500000-9999999
+ 2
+
+
+
+
+ 978-9975
+ Moldova
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-2999999
+ 3
+
+
+ 3000000-3999999
+ 4
+
+
+ 4000000-4499999
+ 4
+
+
+ 4500000-8999999
+ 2
+
+
+ 9000000-9499999
+ 3
+
+
+ 9500000-9999999
+ 4
+
+
+
+
+ 978-9976
+ Tanzania
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-5799999
+ 4
+
+
+ 5800000-5899999
+ 3
+
+
+ 5900000-8999999
+ 2
+
+
+ 9000000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-9977
+ Costa Rica
+
+
+ 0000000-8999999
+ 2
+
+
+ 9000000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-9978
+ Ecuador
+
+
+ 0000000-2999999
+ 2
+
+
+ 3000000-3999999
+ 3
+
+
+ 4000000-9499999
+ 2
+
+
+ 9500000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-9979
+ Iceland
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-6499999
+ 2
+
+
+ 6500000-6599999
+ 3
+
+
+ 6600000-7599999
+ 2
+
+
+ 7600000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9980
+ Papua New Guinea
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-8999999
+ 2
+
+
+ 9000000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-9981
+ Morocco
+
+
+ 0000000-0999999
+ 2
+
+
+ 1000000-1599999
+ 3
+
+
+ 1600000-1999999
+ 4
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9499999
+ 3
+
+
+ 9500000-9999999
+ 4
+
+
+
+
+ 978-9982
+ Zambia
+
+
+ 0000000-7999999
+ 2
+
+
+ 8000000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-9983
+ Gambia
+
+
+ 0000000-7999999
+ 0
+
+
+ 8000000-9499999
+ 2
+
+
+ 9500000-9899999
+ 3
+
+
+ 9900000-9999999
+ 4
+
+
+
+
+ 978-9984
+ Latvia
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9985
+ Estonia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 4
+
+
+
+
+ 978-9986
+ Lithuania
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-8999999
+ 3
+
+
+ 9000000-9399999
+ 4
+
+
+ 9400000-9699999
+ 3
+
+
+ 9700000-9999999
+ 2
+
+
+
+
+ 978-9987
+ Tanzania
+
+
+ 0000000-3999999
+ 2
+
+
+ 4000000-8799999
+ 3
+
+
+ 8800000-9999999
+ 4
+
+
+
+
+ 978-9988
+ Ghana
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-5499999
+ 2
+
+
+ 5500000-7499999
+ 3
+
+
+ 7500000-9999999
+ 4
+
+
+
+
+ 978-9989
+ North Macedonia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1999999
+ 3
+
+
+ 2000000-2999999
+ 4
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-9499999
+ 3
+
+
+ 9500000-9999999
+ 4
+
+
+
+
+ 978-99901
+ Bahrain
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-7999999
+ 3
+
+
+ 8000000-9999999
+ 2
+
+
+
+
+ 978-99902
+ Reserved Agency
+
+
+ 0000000-9999999
+ 0
+
+
+
+
+ 978-99903
+ Mauritius
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99904
+ Curaçao
+
+
+ 0000000-5999999
+ 1
+
+
+ 6000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99905
+ Bolivia
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99906
+ Kuwait
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-6999999
+ 3
+
+
+ 7000000-8999999
+ 2
+
+
+ 9000000-9499999
+ 2
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99908
+ Malawi
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99909
+ Malta
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-9499999
+ 2
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99910
+ Sierra Leone
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99911
+ Lesotho
+
+
+ 0000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99912
+ Botswana
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-5999999
+ 3
+
+
+ 6000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99913
+ Andorra
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-3599999
+ 2
+
+
+ 3600000-5999999
+ 0
+
+
+ 6000000-6049999
+ 3
+
+
+ 6050000-9999999
+ 0
+
+
+
+
+ 978-99914
+ International NGO Publishers
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-6999999
+ 2
+
+
+ 7000000-7999999
+ 1
+
+
+ 8000000-8699999
+ 2
+
+
+ 8700000-8799999
+ 3
+
+
+ 8800000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99915
+ Maldives
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99916
+ Namibia
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-6999999
+ 2
+
+
+ 7000000-9999999
+ 3
+
+
+
+
+ 978-99917
+ Brunei Darussalam
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-8899999
+ 2
+
+
+ 8900000-9999999
+ 3
+
+
+
+
+ 978-99918
+ Faroe Islands
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99919
+ Benin
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-3999999
+ 3
+
+
+ 4000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99920
+ Andorra
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99921
+ Qatar
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-6999999
+ 2
+
+
+ 7000000-7999999
+ 3
+
+
+ 8000000-8999999
+ 1
+
+
+ 9000000-9999999
+ 2
+
+
+
+
+ 978-99922
+ Guatemala
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-6999999
+ 2
+
+
+ 7000000-9999999
+ 3
+
+
+
+
+ 978-99923
+ El Salvador
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99924
+ Nicaragua
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99925
+ Paraguay
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1999999
+ 2
+
+
+ 2000000-2999999
+ 3
+
+
+ 3000000-3999999
+ 1
+
+
+ 4000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99926
+ Honduras
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-5999999
+ 2
+
+
+ 6000000-8699999
+ 3
+
+
+ 8700000-8999999
+ 2
+
+
+ 9000000-9999999
+ 2
+
+
+
+
+ 978-99927
+ Albania
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99928
+ Georgia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99929
+ Mongolia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99930
+ Armenia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99931
+ Seychelles
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99932
+ Malta
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-5999999
+ 2
+
+
+ 6000000-6999999
+ 3
+
+
+ 7000000-7999999
+ 1
+
+
+ 8000000-9999999
+ 2
+
+
+
+
+ 978-99933
+ Nepal
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99934
+ Dominican Republic
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99935
+ Haiti
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-6999999
+ 3
+
+
+ 7000000-8999999
+ 1
+
+
+ 9000000-9999999
+ 2
+
+
+
+
+ 978-99936
+ Bhutan
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99937
+ Macau
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99938
+ Srpska, Republic of
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-5999999
+ 2
+
+
+ 6000000-8999999
+ 3
+
+
+ 9000000-9999999
+ 2
+
+
+
+
+ 978-99939
+ Guatemala
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99940
+ Georgia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-6999999
+ 2
+
+
+ 7000000-9999999
+ 3
+
+
+
+
+ 978-99941
+ Armenia
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99942
+ Sudan
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99943
+ Albania
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99944
+ Ethiopia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99945
+ Namibia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99946
+ Nepal
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99947
+ Tajikistan
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-6999999
+ 2
+
+
+ 7000000-9999999
+ 3
+
+
+
+
+ 978-99948
+ Eritrea
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99949
+ Mauritius
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-8999999
+ 1
+
+
+ 9000000-9899999
+ 3
+
+
+ 9900000-9999999
+ 2
+
+
+
+
+ 978-99950
+ Cambodia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99951
+ Reserved Agency
+
+
+ 0000000-9999999
+ 0
+
+
+
+
+ 978-99952
+ Mali
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99953
+ Paraguay
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-7999999
+ 2
+
+
+ 8000000-9399999
+ 3
+
+
+ 9400000-9999999
+ 2
+
+
+
+
+ 978-99954
+ Bolivia
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-6999999
+ 2
+
+
+ 7000000-8799999
+ 3
+
+
+ 8800000-9999999
+ 2
+
+
+
+
+ 978-99955
+ Srpska, Republic of
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-5999999
+ 2
+
+
+ 6000000-7999999
+ 3
+
+
+ 8000000-9999999
+ 2
+
+
+
+
+ 978-99956
+ Albania
+
+
+ 0000000-5999999
+ 2
+
+
+ 6000000-8599999
+ 3
+
+
+ 8600000-9999999
+ 2
+
+
+
+
+ 978-99957
+ Malta
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9499999
+ 3
+
+
+ 9500000-9999999
+ 2
+
+
+
+
+ 978-99958
+ Bahrain
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-9399999
+ 2
+
+
+ 9400000-9499999
+ 3
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99959
+ Luxembourg
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99960
+ Malawi
+
+
+ 0000000-0699999
+ 0
+
+
+ 0700000-0999999
+ 3
+
+
+ 1000000-9499999
+ 2
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99961
+ El Salvador
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-3699999
+ 3
+
+
+ 3700000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99962
+ Mongolia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99963
+ Cambodia
+
+
+ 0000000-4999999
+ 2
+
+
+ 5000000-9199999
+ 3
+
+
+ 9200000-9999999
+ 2
+
+
+
+
+ 978-99964
+ Nicaragua
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99965
+ Macau
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-3599999
+ 3
+
+
+ 3600000-6299999
+ 2
+
+
+ 6300000-9999999
+ 3
+
+
+
+
+ 978-99966
+ Kuwait
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-6999999
+ 2
+
+
+ 7000000-7999999
+ 3
+
+
+ 8000000-9699999
+ 2
+
+
+ 9700000-9999999
+ 3
+
+
+
+
+ 978-99967
+ Paraguay
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-5999999
+ 2
+
+
+ 6000000-9999999
+ 3
+
+
+
+
+ 978-99968
+ Botswana
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-5999999
+ 3
+
+
+ 6000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99969
+ Oman
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-7999999
+ 2
+
+
+ 8000000-9499999
+ 3
+
+
+ 9500000-9999999
+ 2
+
+
+
+
+ 978-99970
+ Haiti
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99971
+ Myanmar
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-8499999
+ 2
+
+
+ 8500000-9999999
+ 3
+
+
+
+
+ 978-99972
+ Faroe Islands
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99973
+ Mongolia
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99974
+ Bolivia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-2599999
+ 2
+
+
+ 2600000-3999999
+ 3
+
+
+ 4000000-6399999
+ 2
+
+
+ 6400000-6499999
+ 3
+
+
+ 6500000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99975
+ Tajikistan
+
+
+ 0000000-2999999
+ 1
+
+
+ 3000000-3999999
+ 3
+
+
+ 4000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99976
+ Srpska, Republic of
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-1599999
+ 2
+
+
+ 1600000-1999999
+ 3
+
+
+ 2000000-5999999
+ 2
+
+
+ 6000000-8199999
+ 3
+
+
+ 8200000-8999999
+ 2
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99977
+ Rwanda
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3999999
+ 0
+
+
+ 4000000-6999999
+ 2
+
+
+ 7000000-7999999
+ 3
+
+
+ 8000000-9749999
+ 0
+
+
+ 9750000-9999999
+ 3
+
+
+
+
+ 978-99978
+ Mongolia
+
+
+ 0000000-4999999
+ 1
+
+
+ 5000000-6999999
+ 2
+
+
+ 7000000-9999999
+ 3
+
+
+
+
+ 978-99979
+ Honduras
+
+
+ 0000000-3999999
+ 1
+
+
+ 4000000-7999999
+ 2
+
+
+ 8000000-9999999
+ 3
+
+
+
+
+ 978-99980
+ Bhutan
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-2999999
+ 0
+
+
+ 3000000-5999999
+ 2
+
+
+ 6000000-7499999
+ 0
+
+
+ 7500000-9999999
+ 3
+
+
+
+
+ 978-99981
+ Macau
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-2699999
+ 0
+
+
+ 2700000-7499999
+ 2
+
+
+ 7500000-9999999
+ 3
+
+
+
+
+ 978-99982
+ Benin
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-4999999
+ 0
+
+
+ 5000000-6899999
+ 2
+
+
+ 6900000-8999999
+ 0
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99983
+ El Salvador
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-6999999
+ 2
+
+
+ 7000000-9499999
+ 0
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99984
+ Brunei Darussalam
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-6999999
+ 2
+
+
+ 7000000-9499999
+ 0
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99985
+ Tajikistan
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-3499999
+ 0
+
+
+ 3500000-7999999
+ 2
+
+
+ 8000000-8499999
+ 0
+
+
+ 8500000-9999999
+ 3
+
+
+
+
+ 978-99986
+ Myanmar
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-6999999
+ 2
+
+
+ 7000000-9499999
+ 0
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99987
+ Luxembourg
+
+
+ 0000000-6999999
+ 0
+
+
+ 7000000-9999999
+ 3
+
+
+
+
+ 978-99988
+ Sudan
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-5499999
+ 2
+
+
+ 5500000-7999999
+ 0
+
+
+ 8000000-8249999
+ 3
+
+
+ 8250000-9999999
+ 0
+
+
+
+
+ 978-99989
+ Paraguay
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-6499999
+ 2
+
+
+ 6500000-8999999
+ 0
+
+
+ 9000000-9999999
+ 3
+
+
+
+
+ 978-99990
+ Ethiopia
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-5499999
+ 2
+
+
+ 5500000-9749999
+ 0
+
+
+ 9750000-9999999
+ 3
+
+
+
+
+ 978-99992
+ Oman
+
+
+ 0000000-1999999
+ 1
+
+
+ 2000000-4999999
+ 0
+
+
+ 5000000-6499999
+ 2
+
+
+ 6500000-9499999
+ 0
+
+
+ 9500000-9999999
+ 3
+
+
+
+
+ 978-99993
+ Mauritius
+
+
+ 0000000-0999999
+ 1
+
+
+ 1000000-4999999
+ 0
+
+
+ 5000000-5499999
+ 2
+
+
+ 5500000-9799999
+ 0
+
+
+ 9800000-9999999
+ 3
+
+
+
+
+ 979-10
+ France
+
+
+ 0000000-1999999
+ 2
+
+
+ 2000000-6999999
+ 3
+
+
+ 7000000-8999999
+ 4
+
+
+ 9000000-9759999
+ 5
+
+
+ 9760000-9999999
+ 6
+
+
+
+
+ 979-11
+ Korea, Republic
+
+
+ 0000000-2499999
+ 2
+
+
+ 2500000-5499999
+ 3
+
+
+ 5500000-8499999
+ 4
+
+
+ 8500000-9499999
+ 5
+
+
+ 9500000-9999999
+ 6
+
+
+
+
+ 979-12
+ Italy
+
+
+ 0000000-1999999
+ 0
+
+
+ 2000000-2999999
+ 3
+
+
+ 3000000-5449999
+ 0
+
+
+ 5450000-5999999
+ 4
+
+
+ 6000000-7999999
+ 0
+
+
+ 8000000-8499999
+ 5
+
+
+ 8500000-9999999
+ 0
+
+
+
+
+ 979-8
+ United States
+
+
+ 0000000-1999999
+ 0
+
+
+ 2000000-2299999
+ 3
+
+
+ 2300000-3499999
+ 0
+
+
+ 3500000-3999999
+ 4
+
+
+ 4000000-8499999
+ 4
+
+
+ 8500000-8849999
+ 4
+
+
+ 8850000-8999999
+ 5
+
+
+ 9000000-9849999
+ 0
+
+
+ 9850000-9899999
+ 7
+
+
+ 9900000-9999999
+ 0
+
+
+
+
+
diff --git a/bookwyrm/isbn/__init__.py b/bookwyrm/isbn/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/bookwyrm/isbn/isbn.py b/bookwyrm/isbn/isbn.py
new file mode 100644
index 000000000..d14dc2619
--- /dev/null
+++ b/bookwyrm/isbn/isbn.py
@@ -0,0 +1,128 @@
+""" Use the range message from isbn-international to hyphenate ISBNs """
+import os
+from typing import Optional
+from xml.etree import ElementTree
+from xml.etree.ElementTree import Element
+
+import requests
+
+from bookwyrm import settings
+
+
+def _get_rules(element: Element) -> list[Element]:
+ if (rules_el := element.find("Rules")) is not None:
+ return rules_el.findall("Rule")
+ return []
+
+
+class IsbnHyphenator:
+ """Class to manage the range message xml file and use it to hyphenate ISBNs"""
+
+ __range_message_url = "https://www.isbn-international.org/export_rangemessage.xml"
+ __range_file_path = os.path.join(
+ settings.BASE_DIR, "bookwyrm", "isbn", "RangeMessage.xml"
+ )
+ __element_tree = None
+
+ def update_range_message(self) -> None:
+ """Download the range message xml file and save it locally"""
+ response = requests.get(self.__range_message_url, timeout=15)
+ with open(self.__range_file_path, "w", encoding="utf-8") as file:
+ file.write(response.text)
+ self.__element_tree = None
+
+ def hyphenate(self, isbn_13: Optional[str]) -> Optional[str]:
+ """hyphenate the given ISBN-13 number using the range message"""
+ if isbn_13 is None:
+ return None
+
+ if self.__element_tree is None:
+ self.__element_tree = ElementTree.parse(self.__range_file_path)
+
+ gs1_prefix = isbn_13[:3]
+ try:
+ reg_group = self.__find_reg_group(isbn_13, gs1_prefix)
+ except ValueError:
+ # if the reg groups are invalid, just return the original isbn
+ return isbn_13
+
+ if reg_group is None:
+ return isbn_13 # failed to hyphenate
+
+ registrant = self.__find_registrant(isbn_13, gs1_prefix, reg_group)
+ if registrant is None:
+ return isbn_13 # failed to hyphenate
+
+ publication = isbn_13[len(gs1_prefix) + len(reg_group) + len(registrant) : -1]
+ check_digit = isbn_13[-1:]
+ return "-".join((gs1_prefix, reg_group, registrant, publication, check_digit))
+
+ def __find_reg_group(self, isbn_13: str, gs1_prefix: str) -> Optional[str]:
+ if self.__element_tree is None:
+ self.__element_tree = ElementTree.parse(self.__range_file_path)
+
+ ucc_prefixes_el = self.__element_tree.find("EAN.UCCPrefixes")
+ if ucc_prefixes_el is None:
+ return None
+
+ for ean_ucc_el in ucc_prefixes_el.findall("EAN.UCC"):
+ if (
+ prefix_el := ean_ucc_el.find("Prefix")
+ ) is not None and prefix_el.text == gs1_prefix:
+ for rule_el in _get_rules(ean_ucc_el):
+ length_el = rule_el.find("Length")
+ if length_el is None:
+ continue
+ length = int(text) if (text := length_el.text) else 0
+ if length == 0:
+ continue
+
+ range_el = rule_el.find("Range")
+ if range_el is None or range_el.text is None:
+ continue
+
+ reg_grp_range = [int(x[:length]) for x in range_el.text.split("-")]
+ reg_group = isbn_13[len(gs1_prefix) : len(gs1_prefix) + length]
+ if reg_grp_range[0] <= int(reg_group) <= reg_grp_range[1]:
+ return reg_group
+ return None
+ return None
+
+ def __find_registrant(
+ self, isbn_13: str, gs1_prefix: str, reg_group: str
+ ) -> Optional[str]:
+ from_ind = len(gs1_prefix) + len(reg_group)
+
+ if self.__element_tree is None:
+ self.__element_tree = ElementTree.parse(self.__range_file_path)
+
+ reg_groups_el = self.__element_tree.find("RegistrationGroups")
+ if reg_groups_el is None:
+ return None
+
+ for group_el in reg_groups_el.findall("Group"):
+ if (
+ prefix_el := group_el.find("Prefix")
+ ) is not None and prefix_el.text == "-".join((gs1_prefix, reg_group)):
+ for rule_el in _get_rules(group_el):
+ length_el = rule_el.find("Length")
+ if length_el is None:
+ continue
+ length = int(text) if (text := length_el.text) else 0
+ if length == 0:
+ continue
+
+ range_el = rule_el.find("Range")
+ if range_el is None or range_el.text is None:
+ continue
+ registrant_range = [
+ int(x[:length]) for x in range_el.text.split("-")
+ ]
+ registrant = isbn_13[from_ind : from_ind + length]
+ if registrant_range[0] <= int(registrant) <= registrant_range[1]:
+ return registrant
+ return None
+ return None
+
+
+hyphenator_singleton = IsbnHyphenator()
diff --git a/bookwyrm/lists_stream.py b/bookwyrm/lists_stream.py
index 148b81a78..479eacb19 100644
--- a/bookwyrm/lists_stream.py
+++ b/bookwyrm/lists_stream.py
@@ -18,7 +18,7 @@ class ListsStream(RedisStore):
return f"{user}-lists"
return f"{user.id}-lists"
- def get_rank(self, obj): # pylint: disable=no-self-use
+ def get_rank(self, obj):
"""lists are sorted by updated date"""
return obj.updated_date.timestamp()
diff --git a/bookwyrm/management/commands/deduplicate_book_data.py b/bookwyrm/management/commands/deduplicate_book_data.py
index dde7d133c..c2d897ce3 100644
--- a/bookwyrm/management/commands/deduplicate_book_data.py
+++ b/bookwyrm/management/commands/deduplicate_book_data.py
@@ -1,13 +1,14 @@
""" PROCEED WITH CAUTION: uses deduplication fields to permanently
merge book data objects """
+
from django.core.management.base import BaseCommand
from django.db.models import Count
from bookwyrm import models
-from bookwyrm.management.merge import merge_objects
-def dedupe_model(model):
+def dedupe_model(model, dry_run=False):
"""combine duplicate editions and update related models"""
+ print(f"deduplicating {model.__name__}:")
fields = model._meta.get_fields()
dedupe_fields = [
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
@@ -16,30 +17,42 @@ def dedupe_model(model):
dupes = (
model.objects.values(field.name)
.annotate(Count(field.name))
- .filter(**{"%s__count__gt" % field.name: 1})
+ .filter(**{f"{field.name}__count__gt": 1})
+ .exclude(**{field.name: ""})
+ .exclude(**{f"{field.name}__isnull": True})
)
for dupe in dupes:
value = dupe[field.name]
- if not value or value == "":
- continue
print("----------")
- print(dupe)
objs = model.objects.filter(**{field.name: value}).order_by("id")
canonical = objs.first()
- print("keeping", canonical.remote_id)
+ action = "would merge" if dry_run else "merging"
+ print(
+ f"{action} into {model.__name__} {canonical.remote_id} based on {field.name} {value}:"
+ )
for obj in objs[1:]:
- print(obj.remote_id)
- merge_objects(canonical, obj)
+ print(f"- {obj.remote_id}")
+ absorbed_fields = obj.merge_into(canonical, dry_run=dry_run)
+ print(f" absorbed fields: {absorbed_fields}")
class Command(BaseCommand):
"""deduplicate allllll the book data models"""
help = "merges duplicate book data"
+
+ def add_arguments(self, parser):
+ """add the arguments for this command"""
+ parser.add_argument(
+ "--dry_run",
+ action="store_true",
+ help="don't actually merge, only print what would happen",
+ )
+
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run deduplications"""
- dedupe_model(models.Edition)
- dedupe_model(models.Work)
- dedupe_model(models.Author)
+ dedupe_model(models.Edition, dry_run=options["dry_run"])
+ dedupe_model(models.Work, dry_run=options["dry_run"])
+ dedupe_model(models.Author, dry_run=options["dry_run"])
diff --git a/bookwyrm/management/commands/erase_deleted_user_data.py b/bookwyrm/management/commands/erase_deleted_user_data.py
new file mode 100644
index 000000000..40c3f042b
--- /dev/null
+++ b/bookwyrm/management/commands/erase_deleted_user_data.py
@@ -0,0 +1,43 @@
+""" Erase any data stored about deleted users """
+import sys
+from django.core.management.base import BaseCommand, CommandError
+from bookwyrm import models
+from bookwyrm.models.user import erase_user_data
+
+# pylint: disable=missing-function-docstring
+class Command(BaseCommand):
+ """command-line options"""
+
+ help = "Remove Two Factor Authorisation from user"
+
+ def add_arguments(self, parser): # pylint: disable=no-self-use
+ parser.add_argument(
+ "--dryrun",
+ action="store_true",
+ help="Preview users to be cleared without altering the database",
+ )
+
+ def handle(self, *args, **options): # pylint: disable=unused-argument
+
+ # Check for anything fishy
+ bad_state = models.User.objects.filter(is_deleted=True, is_active=True)
+ if bad_state.exists():
+ raise CommandError(
+ f"{bad_state.count()} user(s) marked as both active and deleted"
+ )
+
+ deleted_users = models.User.objects.filter(is_deleted=True)
+ self.stdout.write(f"Found {deleted_users.count()} deleted users")
+ if options["dryrun"]:
+ self.stdout.write("\n".join(u.username for u in deleted_users[:5]))
+ if deleted_users.count() > 5:
+ self.stdout.write("... and more")
+ sys.exit()
+
+ self.stdout.write("Erasing user data:")
+ for user_id in deleted_users.values_list("id", flat=True):
+ erase_user_data.delay(user_id)
+ self.stdout.write(".", ending="")
+
+ self.stdout.write("")
+ self.stdout.write("Tasks created successfully")
diff --git a/bookwyrm/management/commands/instance_version.py b/bookwyrm/management/commands/instance_version.py
deleted file mode 100644
index ca150d640..000000000
--- a/bookwyrm/management/commands/instance_version.py
+++ /dev/null
@@ -1,54 +0,0 @@
-""" Get your admin code to allow install """
-from django.core.management.base import BaseCommand
-
-from bookwyrm import models
-from bookwyrm.settings import VERSION
-
-
-# pylint: disable=no-self-use
-class Command(BaseCommand):
- """command-line options"""
-
- help = "What version is this?"
-
- def add_arguments(self, parser):
- """specify which function to run"""
- parser.add_argument(
- "--current",
- action="store_true",
- help="Version stored in database",
- )
- parser.add_argument(
- "--target",
- action="store_true",
- help="Version stored in settings",
- )
- parser.add_argument(
- "--update",
- action="store_true",
- help="Update database version",
- )
-
- # pylint: disable=unused-argument
- def handle(self, *args, **options):
- """execute init"""
- site = models.SiteSettings.objects.get()
- current = site.version or "0.0.1"
- target = VERSION
- if options.get("current"):
- print(current)
- return
-
- if options.get("target"):
- print(target)
- return
-
- if options.get("update"):
- site.version = target
- site.save()
- return
-
- if current != target:
- print(f"{current}/{target}")
- else:
- print(current)
diff --git a/bookwyrm/management/commands/repair_editions.py b/bookwyrm/management/commands/repair_editions.py
new file mode 100644
index 000000000..304cd5e51
--- /dev/null
+++ b/bookwyrm/management/commands/repair_editions.py
@@ -0,0 +1,21 @@
+""" Repair editions with missing works """
+from django.core.management.base import BaseCommand
+from bookwyrm import models
+
+
+class Command(BaseCommand):
+ """command-line options"""
+
+ help = "Repairs an edition that is in a broken state"
+
+ # pylint: disable=unused-argument
+ def handle(self, *args, **options):
+ """Find and repair broken editions"""
+ # Find broken editions
+ editions = models.Edition.objects.filter(parent_work__isnull=True)
+ self.stdout.write(f"Repairing {editions.count()} edition(s):")
+
+ # Do repair
+ for edition in editions:
+ edition.repair()
+ self.stdout.write(".", ending="")
diff --git a/bookwyrm/management/merge.py b/bookwyrm/management/merge.py
deleted file mode 100644
index f55229f18..000000000
--- a/bookwyrm/management/merge.py
+++ /dev/null
@@ -1,50 +0,0 @@
-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 aren’t 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 won’t 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()
diff --git a/bookwyrm/management/merge_command.py b/bookwyrm/management/merge_command.py
index 805dc73fa..66e60814a 100644
--- a/bookwyrm/management/merge_command.py
+++ b/bookwyrm/management/merge_command.py
@@ -1,4 +1,3 @@
-from bookwyrm.management.merge import merge_objects
from django.core.management.base import BaseCommand
@@ -9,6 +8,11 @@ class MergeCommand(BaseCommand):
"""add the arguments for this command"""
parser.add_argument("--canonical", type=int, required=True)
parser.add_argument("--other", type=int, required=True)
+ parser.add_argument(
+ "--dry_run",
+ action="store_true",
+ help="don't actually merge, only print what would happen",
+ )
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
@@ -26,4 +30,8 @@ class MergeCommand(BaseCommand):
print("other book doesn’t exist!")
return
- merge_objects(canonical, other)
+ absorbed_fields = other.merge_into(canonical, dry_run=options["dry_run"])
+
+ action = "would be" if options["dry_run"] else "has been"
+ print(f"{other.remote_id} {action} merged into {canonical.remote_id}")
+ print(f"absorbed fields: {absorbed_fields}")
diff --git a/bookwyrm/middleware/__init__.py b/bookwyrm/middleware/__init__.py
index 03843c5a3..85c3a56fe 100644
--- a/bookwyrm/middleware/__init__.py
+++ b/bookwyrm/middleware/__init__.py
@@ -1,3 +1,4 @@
""" look at all this nice middleware! """
from .timezone_middleware import TimezoneMiddleware
from .ip_middleware import IPBlocklistMiddleware
+from .file_too_big import FileTooBig
diff --git a/bookwyrm/middleware/file_too_big.py b/bookwyrm/middleware/file_too_big.py
new file mode 100644
index 000000000..de1349d96
--- /dev/null
+++ b/bookwyrm/middleware/file_too_big.py
@@ -0,0 +1,30 @@
+"""Middleware to display a custom 413 error page"""
+
+from django.http import HttpResponse
+from django.shortcuts import render
+from django.core.exceptions import RequestDataTooBig
+
+
+class FileTooBig:
+ """Middleware to display a custom page when a
+ RequestDataTooBig exception is thrown"""
+
+ def __init__(self, get_response):
+ """boilerplate __init__ from Django docs"""
+
+ self.get_response = get_response
+
+ def __call__(self, request):
+ """If RequestDataTooBig is thrown, render the 413 error page"""
+
+ try:
+ body = request.body # pylint: disable=unused-variable
+
+ except RequestDataTooBig:
+
+ rendered = render(request, "413.html")
+ response = HttpResponse(rendered)
+ return response
+
+ response = self.get_response(request)
+ return response
diff --git a/bookwyrm/middleware/timezone_middleware.py b/bookwyrm/middleware/timezone_middleware.py
index 5033397a5..3cf084154 100644
--- a/bookwyrm/middleware/timezone_middleware.py
+++ b/bookwyrm/middleware/timezone_middleware.py
@@ -1,5 +1,5 @@
""" Makes the app aware of the users timezone """
-import pytz
+import zoneinfo
from django.utils import timezone
@@ -12,9 +12,7 @@ class TimezoneMiddleware:
def __call__(self, request):
if request.user.is_authenticated:
- timezone.activate(pytz.timezone(request.user.preferred_timezone))
+ timezone.activate(zoneinfo.ZoneInfo(request.user.preferred_timezone))
else:
- timezone.activate(pytz.utc)
- response = self.get_response(request)
- timezone.deactivate()
- return response
+ timezone.deactivate()
+ return self.get_response(request)
diff --git a/bookwyrm/migrations/0171_alter_user_preferred_timezone.py b/bookwyrm/migrations/0171_alter_user_preferred_timezone.py
index 7dcd9546c..8d1dff553 100644
--- a/bookwyrm/migrations/0171_alter_user_preferred_timezone.py
+++ b/bookwyrm/migrations/0171_alter_user_preferred_timezone.py
@@ -10,6 +10,7 @@ class Migration(migrations.Migration):
]
operations = [
+ # The new timezones are "Factory" and "localtime"
migrations.AlterField(
model_name="user",
name="preferred_timezone",
diff --git a/bookwyrm/migrations/0179_populate_sort_title.py b/bookwyrm/migrations/0179_populate_sort_title.py
index e238bca1d..a149a68a7 100644
--- a/bookwyrm/migrations/0179_populate_sort_title.py
+++ b/bookwyrm/migrations/0179_populate_sort_title.py
@@ -45,5 +45,7 @@ class Migration(migrations.Migration):
]
operations = [
- migrations.RunPython(populate_sort_title),
+ migrations.RunPython(
+ populate_sort_title, reverse_code=migrations.RunPython.noop
+ ),
]
diff --git a/bookwyrm/migrations/0179_reportcomment_comment_type.py b/bookwyrm/migrations/0179_reportcomment_comment_type.py
new file mode 100644
index 000000000..a8a446096
--- /dev/null
+++ b/bookwyrm/migrations/0179_reportcomment_comment_type.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.2.18 on 2023-05-16 16:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0178_auto_20230328_2132"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="reportcomment",
+ name="action_type",
+ field=models.CharField(
+ choices=[
+ ("comment", "Comment"),
+ ("resolve", "Resolved report"),
+ ("reopen", "Re-opened report"),
+ ("message_reporter", "Messaged reporter"),
+ ("message_offender", "Messaged reported user"),
+ ("user_suspension", "Suspended user"),
+ ("user_unsuspension", "Un-suspended user"),
+ ("user_perms", "Changed user permission level"),
+ ("user_deletion", "Deleted user account"),
+ ("block_domain", "Blocked domain"),
+ ("approve_domain", "Approved domain"),
+ ("delete_item", "Deleted item"),
+ ],
+ default="comment",
+ max_length=20,
+ ),
+ ),
+ migrations.RenameModel("ReportComment", "ReportAction"),
+ ]
diff --git a/bookwyrm/migrations/0180_alter_reportaction_options.py b/bookwyrm/migrations/0180_alter_reportaction_options.py
new file mode 100644
index 000000000..2979d266e
--- /dev/null
+++ b/bookwyrm/migrations/0180_alter_reportaction_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.18 on 2023-06-21 22:01
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0179_reportcomment_comment_type"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="reportaction",
+ options={"ordering": ("created_date",)},
+ ),
+ ]
diff --git a/bookwyrm/migrations/0180_alter_user_preferred_language.py b/bookwyrm/migrations/0180_alter_user_preferred_language.py
new file mode 100644
index 000000000..b4ab996ec
--- /dev/null
+++ b/bookwyrm/migrations/0180_alter_user_preferred_language.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.2.19 on 2023-07-23 19:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0179_populate_sort_title"),
+ ]
+
+ operations = [
+ 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)"),
+ ("nl-nl", "Nederlands (Dutch)"),
+ ("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,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0181_merge_20230806_2302.py b/bookwyrm/migrations/0181_merge_20230806_2302.py
new file mode 100644
index 000000000..f4f05b886
--- /dev/null
+++ b/bookwyrm/migrations/0181_merge_20230806_2302.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.20 on 2023-08-06 23:02
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0180_alter_reportaction_options"),
+ ("bookwyrm", "0180_alter_user_preferred_language"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0182_auto_20231027_1122.py b/bookwyrm/migrations/0182_auto_20231027_1122.py
new file mode 100644
index 000000000..ab57907a9
--- /dev/null
+++ b/bookwyrm/migrations/0182_auto_20231027_1122.py
@@ -0,0 +1,130 @@
+# Generated by Django 3.2.20 on 2023-10-27 11:22
+
+import bookwyrm.models.activitypub_mixin
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0181_merge_20230806_2302"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="also_known_as",
+ field=bookwyrm.models.fields.ManyToManyField(to=settings.AUTH_USER_MODEL),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="moved_to",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ 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"),
+ ("MOVE", "Move"),
+ ],
+ max_length=255,
+ ),
+ ),
+ migrations.CreateModel(
+ name="Move",
+ 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],
+ ),
+ ),
+ ("object", bookwyrm.models.fields.CharField(max_length=255)),
+ (
+ "origin",
+ bookwyrm.models.fields.CharField(
+ blank=True, default="", max_length=255, null=True
+ ),
+ ),
+ (
+ "user",
+ bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=(bookwyrm.models.activitypub_mixin.ActivityMixin, models.Model),
+ ),
+ migrations.CreateModel(
+ name="MoveUser",
+ fields=[
+ (
+ "move_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.move",
+ ),
+ ),
+ (
+ "target",
+ bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="move_target",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.move",),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0183_auto_20231105_1607.py b/bookwyrm/migrations/0183_auto_20231105_1607.py
new file mode 100644
index 000000000..0c8376adc
--- /dev/null
+++ b/bookwyrm/migrations/0183_auto_20231105_1607.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.20 on 2023-11-05 16:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0182_auto_20231027_1122"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="is_deleted",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0184_auto_20231106_0421.py b/bookwyrm/migrations/0184_auto_20231106_0421.py
new file mode 100644
index 000000000..23bacc502
--- /dev/null
+++ b/bookwyrm/migrations/0184_auto_20231106_0421.py
@@ -0,0 +1,35 @@
+# Generated by Django 3.2.20 on 2023-11-06 04:21
+
+from django.db import migrations
+from bookwyrm.models import User
+
+
+def update_deleted_users(apps, schema_editor):
+ """Find all the users who are deleted, not just inactive, and set deleted"""
+ users = apps.get_model("bookwyrm", "User")
+ db_alias = schema_editor.connection.alias
+ users.objects.using(db_alias).filter(
+ is_active=False,
+ deactivation_reason__in=[
+ "self_deletion",
+ "moderator_deletion",
+ ],
+ ).update(is_deleted=True)
+
+ # differente rules for remote users
+ users.objects.using(db_alias).filter(is_active=False, local=False,).exclude(
+ deactivation_reason="moderator_deactivation",
+ ).update(is_deleted=True)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0183_auto_20231105_1607"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ update_deleted_users, reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/bookwyrm/migrations/0185_alter_notification_notification_type.py b/bookwyrm/migrations/0185_alter_notification_notification_type.py
new file mode 100644
index 000000000..dd070c634
--- /dev/null
+++ b/bookwyrm/migrations/0185_alter_notification_notification_type.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.2.20 on 2023-11-13 22:39
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0184_auto_20231106_0421"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("BOOST", "Boost"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("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"),
+ ("MOVE", "Move"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0186_auto_20231116_0048.py b/bookwyrm/migrations/0186_auto_20231116_0048.py
new file mode 100644
index 000000000..e3b9da4fe
--- /dev/null
+++ b/bookwyrm/migrations/0186_auto_20231116_0048.py
@@ -0,0 +1,212 @@
+# Generated by Django 3.2.20 on 2023-11-16 00:48
+
+from django.conf import settings
+import django.contrib.postgres.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0185_alter_notification_notification_type"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ParentJob",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("task_id", models.UUIDField(blank=True, null=True, unique=True)),
+ (
+ "created_date",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "updated_date",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ("complete", models.BooleanField(default=False)),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending", "Pending"),
+ ("active", "Active"),
+ ("complete", "Complete"),
+ ("stopped", "Stopped"),
+ ("failed", "Failed"),
+ ],
+ default="pending",
+ max_length=50,
+ null=True,
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.AddField(
+ model_name="sitesettings",
+ name="user_import_time_limit",
+ field=models.IntegerField(default=48),
+ ),
+ migrations.AlterField(
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("BOOST", "Boost"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("IMPORT", "Import"),
+ ("USER_IMPORT", "User Import"),
+ ("USER_EXPORT", "User Export"),
+ ("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"),
+ ("MOVE", "Move"),
+ ],
+ max_length=255,
+ ),
+ ),
+ migrations.CreateModel(
+ name="BookwyrmExportJob",
+ fields=[
+ (
+ "parentjob_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.parentjob",
+ ),
+ ),
+ ("export_data", models.FileField(null=True, upload_to="")),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.parentjob",),
+ ),
+ migrations.CreateModel(
+ name="BookwyrmImportJob",
+ fields=[
+ (
+ "parentjob_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.parentjob",
+ ),
+ ),
+ ("archive_file", models.FileField(blank=True, null=True, upload_to="")),
+ ("import_data", models.JSONField(null=True)),
+ (
+ "required",
+ django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(blank=True, max_length=50),
+ blank=True,
+ size=None,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.parentjob",),
+ ),
+ migrations.CreateModel(
+ name="ChildJob",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("task_id", models.UUIDField(blank=True, null=True, unique=True)),
+ (
+ "created_date",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ (
+ "updated_date",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ("complete", models.BooleanField(default=False)),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("pending", "Pending"),
+ ("active", "Active"),
+ ("complete", "Complete"),
+ ("stopped", "Stopped"),
+ ("failed", "Failed"),
+ ],
+ default="pending",
+ max_length=50,
+ null=True,
+ ),
+ ),
+ (
+ "parent_job",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="child_jobs",
+ to="bookwyrm.parentjob",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.AddField(
+ model_name="notification",
+ name="related_user_export",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.bookwyrmexportjob",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0186_invite_request_notification.py b/bookwyrm/migrations/0186_invite_request_notification.py
new file mode 100644
index 000000000..3680b1de7
--- /dev/null
+++ b/bookwyrm/migrations/0186_invite_request_notification.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.2.20 on 2023-11-14 10:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0185_alter_notification_notification_type"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="notification",
+ name="related_invite_requests",
+ field=models.ManyToManyField(to="bookwyrm.InviteRequest"),
+ ),
+ migrations.AlterField(
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("BOOST", "Boost"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("IMPORT", "Import"),
+ ("ADD", "Add"),
+ ("REPORT", "Report"),
+ ("LINK_DOMAIN", "Link Domain"),
+ ("INVITE_REQUEST", "Invite Request"),
+ ("INVITE", "Invite"),
+ ("ACCEPT", "Accept"),
+ ("JOIN", "Join"),
+ ("LEAVE", "Leave"),
+ ("REMOVE", "Remove"),
+ ("GROUP_PRIVACY", "Group Privacy"),
+ ("GROUP_NAME", "Group Name"),
+ ("GROUP_DESCRIPTION", "Group Description"),
+ ("MOVE", "Move"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0187_partial_publication_dates.py b/bookwyrm/migrations/0187_partial_publication_dates.py
new file mode 100644
index 000000000..10ef599a7
--- /dev/null
+++ b/bookwyrm/migrations/0187_partial_publication_dates.py
@@ -0,0 +1,54 @@
+# Generated by Django 3.2.20 on 2023-11-09 16:57
+
+import bookwyrm.models.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0186_invite_request_notification"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="book",
+ name="first_published_date_precision",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("DAY", "Day prec."),
+ ("MONTH", "Month prec."),
+ ("YEAR", "Year prec."),
+ ],
+ editable=False,
+ max_length=10,
+ null=True,
+ ),
+ ),
+ migrations.AddField(
+ model_name="book",
+ name="published_date_precision",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("DAY", "Day prec."),
+ ("MONTH", "Month prec."),
+ ("YEAR", "Year prec."),
+ ],
+ editable=False,
+ max_length=10,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="book",
+ name="first_published_date",
+ field=bookwyrm.models.fields.PartialDateField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name="book",
+ name="published_date",
+ field=bookwyrm.models.fields.PartialDateField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0188_theme_loads.py b/bookwyrm/migrations/0188_theme_loads.py
new file mode 100644
index 000000000..846aaf549
--- /dev/null
+++ b/bookwyrm/migrations/0188_theme_loads.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2023-11-20 18:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0187_partial_publication_dates"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="theme",
+ name="loads",
+ field=models.BooleanField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0189_alter_user_preferred_language.py b/bookwyrm/migrations/0189_alter_user_preferred_language.py
new file mode 100644
index 000000000..d9d9777c7
--- /dev/null
+++ b/bookwyrm/migrations/0189_alter_user_preferred_language.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.23 on 2023-12-12 23:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0188_theme_loads"),
+ ]
+
+ operations = [
+ 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)"),
+ ("nl-nl", "Nederlands (Dutch)"),
+ ("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)"),
+ ("uk-ua", "Українська (Ukrainian)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0189_importjob_create_shelves.py b/bookwyrm/migrations/0189_importjob_create_shelves.py
new file mode 100644
index 000000000..a1b1fc512
--- /dev/null
+++ b/bookwyrm/migrations/0189_importjob_create_shelves.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2023-11-25 05:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0188_theme_loads"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="importjob",
+ name="create_shelves",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0189_merge_0186_auto_20231116_0048_0188_theme_loads.py b/bookwyrm/migrations/0189_merge_0186_auto_20231116_0048_0188_theme_loads.py
new file mode 100644
index 000000000..eb6238f6e
--- /dev/null
+++ b/bookwyrm/migrations/0189_merge_0186_auto_20231116_0048_0188_theme_loads.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2023-11-22 10:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0186_auto_20231116_0048"),
+ ("bookwyrm", "0188_theme_loads"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0190_alter_notification_notification_type.py b/bookwyrm/migrations/0190_alter_notification_notification_type.py
new file mode 100644
index 000000000..aff54c77b
--- /dev/null
+++ b/bookwyrm/migrations/0190_alter_notification_notification_type.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.23 on 2023-11-23 19:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0189_merge_0186_auto_20231116_0048_0188_theme_loads"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("BOOST", "Boost"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("IMPORT", "Import"),
+ ("USER_IMPORT", "User Import"),
+ ("USER_EXPORT", "User Export"),
+ ("ADD", "Add"),
+ ("REPORT", "Report"),
+ ("LINK_DOMAIN", "Link Domain"),
+ ("INVITE_REQUEST", "Invite Request"),
+ ("INVITE", "Invite"),
+ ("ACCEPT", "Accept"),
+ ("JOIN", "Join"),
+ ("LEAVE", "Leave"),
+ ("REMOVE", "Remove"),
+ ("GROUP_PRIVACY", "Group Privacy"),
+ ("GROUP_NAME", "Group Name"),
+ ("GROUP_DESCRIPTION", "Group Description"),
+ ("MOVE", "Move"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0190_book_search_updates.py b/bookwyrm/migrations/0190_book_search_updates.py
new file mode 100644
index 000000000..52d80fcb9
--- /dev/null
+++ b/bookwyrm/migrations/0190_book_search_updates.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.2.20 on 2023-11-24 17:11
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("bookwyrm", "0188_theme_loads"),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name="author",
+ name="bookwyrm_au_search__b050a8_gin",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0191_merge_20240102_0326.py b/bookwyrm/migrations/0191_merge_20240102_0326.py
new file mode 100644
index 000000000..485c14af8
--- /dev/null
+++ b/bookwyrm/migrations/0191_merge_20240102_0326.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2024-01-02 03:26
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0189_alter_user_preferred_language"),
+ ("bookwyrm", "0190_alter_notification_notification_type"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0191_migrate_search_vec_triggers_to_pgtriggers.py b/bookwyrm/migrations/0191_migrate_search_vec_triggers_to_pgtriggers.py
new file mode 100644
index 000000000..03442298f
--- /dev/null
+++ b/bookwyrm/migrations/0191_migrate_search_vec_triggers_to_pgtriggers.py
@@ -0,0 +1,76 @@
+# Generated by Django 3.2.20 on 2023-11-25 00:47
+
+from importlib import import_module
+import re
+
+from django.db import migrations
+import pgtrigger.compiler
+import pgtrigger.migrations
+
+trigger_migration = import_module("bookwyrm.migrations.0077_auto_20210623_2155")
+
+# it's _very_ convenient for development that this migration be reversible
+search_vector_trigger = trigger_migration.Migration.operations[4]
+author_search_vector_trigger = trigger_migration.Migration.operations[5]
+
+
+assert re.search(r"\bCREATE TRIGGER search_vector_trigger\b", search_vector_trigger.sql)
+assert re.search(
+ r"\bCREATE TRIGGER author_search_vector_trigger\b",
+ author_search_vector_trigger.sql,
+)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("bookwyrm", "0190_book_search_updates"),
+ ]
+
+ operations = [
+ pgtrigger.migrations.AddTrigger(
+ model_name="book",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_search_vector_on_book_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="new.search_vector := setweight(coalesce(nullif(to_tsvector('english', new.title), ''), to_tsvector('simple', new.title)), 'A') || setweight(to_tsvector('english', coalesce(new.subtitle, '')), 'B') || (SELECT setweight(to_tsvector('simple', coalesce(array_to_string(array_agg(bookwyrm_author.name), ' '), '')), 'C') FROM bookwyrm_author LEFT JOIN bookwyrm_book_authors ON bookwyrm_author.id = bookwyrm_book_authors.author_id WHERE bookwyrm_book_authors.book_id = new.id ) || setweight(to_tsvector('english', coalesce(new.series, '')), 'D');RETURN NEW;",
+ hash="77d6399497c0a89b0bf09d296e33c396da63705c",
+ operation='INSERT OR UPDATE OF "title", "subtitle", "series", "search_vector"',
+ pgid="pgtrigger_update_search_vector_on_book_edit_bec58",
+ table="bookwyrm_book",
+ when="BEFORE",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="author",
+ trigger=pgtrigger.compiler.Trigger(
+ name="reset_search_vector_on_author_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="WITH updated_books AS (SELECT book_id FROM bookwyrm_book_authors WHERE author_id = new.id ) UPDATE bookwyrm_book SET search_vector = '' FROM updated_books WHERE id = updated_books.book_id;RETURN NEW;",
+ hash="e7bbf08711ff3724c58f4d92fb7a082ffb3d7826",
+ operation='UPDATE OF "name"',
+ pgid="pgtrigger_reset_search_vector_on_author_edit_a447c",
+ table="bookwyrm_author",
+ when="AFTER",
+ ),
+ ),
+ ),
+ migrations.RunSQL(
+ sql="""DROP TRIGGER IF EXISTS search_vector_trigger ON bookwyrm_book;
+ DROP FUNCTION IF EXISTS book_trigger;
+ """,
+ reverse_sql=search_vector_trigger.sql,
+ ),
+ migrations.RunSQL(
+ sql="""DROP TRIGGER IF EXISTS author_search_vector_trigger ON bookwyrm_author;
+ DROP FUNCTION IF EXISTS author_trigger;
+ """,
+ reverse_sql=author_search_vector_trigger.sql,
+ ),
+ migrations.RunSQL(
+ # Recalculate book search vector for any missed author name changes
+ # due to bug in JOIN in the old trigger.
+ sql="UPDATE bookwyrm_book SET search_vector = NULL;",
+ reverse_sql=migrations.RunSQL.noop,
+ ),
+ ]
diff --git a/bookwyrm/migrations/0192_make_page_positions_text.py b/bookwyrm/migrations/0192_make_page_positions_text.py
new file mode 100644
index 000000000..940a9e941
--- /dev/null
+++ b/bookwyrm/migrations/0192_make_page_positions_text.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.23 on 2024-01-04 23:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0191_merge_20240102_0326"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="quotation",
+ name="endposition",
+ field=models.TextField(blank=True, null=True),
+ ),
+ migrations.AlterField(
+ model_name="quotation",
+ name="position",
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0192_rename_version_sitesettings_available_version.py b/bookwyrm/migrations/0192_rename_version_sitesettings_available_version.py
new file mode 100644
index 000000000..db67b4e92
--- /dev/null
+++ b/bookwyrm/migrations/0192_rename_version_sitesettings_available_version.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2024-01-02 19:36
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0191_merge_20240102_0326"),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name="sitesettings",
+ old_name="version",
+ new_name="available_version",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0192_sitesettings_user_exports_enabled.py b/bookwyrm/migrations/0192_sitesettings_user_exports_enabled.py
new file mode 100644
index 000000000..ec5b411e2
--- /dev/null
+++ b/bookwyrm/migrations/0192_sitesettings_user_exports_enabled.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.23 on 2024-01-16 10:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0191_merge_20240102_0326"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="user_exports_enabled",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0193_auto_20240128_0249.py b/bookwyrm/migrations/0193_auto_20240128_0249.py
new file mode 100644
index 000000000..82e32ee48
--- /dev/null
+++ b/bookwyrm/migrations/0193_auto_20240128_0249.py
@@ -0,0 +1,92 @@
+# Generated by Django 3.2.23 on 2024-01-28 02:49
+
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+from django.core.files.storage import storages
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0192_sitesettings_user_exports_enabled"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="bookwyrmexportjob",
+ name="export_json",
+ field=models.JSONField(
+ encoder=django.core.serializers.json.DjangoJSONEncoder, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="bookwyrmexportjob",
+ name="json_completed",
+ field=models.BooleanField(default=False),
+ ),
+ migrations.AlterField(
+ model_name="bookwyrmexportjob",
+ name="export_data",
+ field=models.FileField(
+ null=True,
+ storage=storages["exports"],
+ upload_to="",
+ ),
+ ),
+ migrations.CreateModel(
+ name="AddFileToTar",
+ fields=[
+ (
+ "childjob_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.childjob",
+ ),
+ ),
+ (
+ "parent_export_job",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="child_edition_export_jobs",
+ to="bookwyrm.bookwyrmexportjob",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.childjob",),
+ ),
+ migrations.CreateModel(
+ name="AddBookToUserExportJob",
+ fields=[
+ (
+ "childjob_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.childjob",
+ ),
+ ),
+ (
+ "edition",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.edition",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.childjob",),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0193_merge_20240203_1539.py b/bookwyrm/migrations/0193_merge_20240203_1539.py
new file mode 100644
index 000000000..a88568ba1
--- /dev/null
+++ b/bookwyrm/migrations/0193_merge_20240203_1539.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2024-02-03 15:39
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0192_make_page_positions_text"),
+ ("bookwyrm", "0192_sitesettings_user_exports_enabled"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0194_merge_20240203_1619.py b/bookwyrm/migrations/0194_merge_20240203_1619.py
new file mode 100644
index 000000000..a5c18e300
--- /dev/null
+++ b/bookwyrm/migrations/0194_merge_20240203_1619.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2024-02-03 16:19
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0192_rename_version_sitesettings_available_version"),
+ ("bookwyrm", "0193_merge_20240203_1539"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0195_alter_user_preferred_language.py b/bookwyrm/migrations/0195_alter_user_preferred_language.py
new file mode 100644
index 000000000..1fbfa7304
--- /dev/null
+++ b/bookwyrm/migrations/0195_alter_user_preferred_language.py
@@ -0,0 +1,46 @@
+# Generated by Django 3.2.23 on 2024-02-21 00:45
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0194_merge_20240203_1619"),
+ ]
+
+ operations = [
+ 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)"),
+ ("ko-kr", "한국어 (Korean)"),
+ ("fi-fi", "Suomi (Finnish)"),
+ ("fr-fr", "Français (French)"),
+ ("lt-lt", "Lietuvių (Lithuanian)"),
+ ("nl-nl", "Nederlands (Dutch)"),
+ ("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)"),
+ ("uk-ua", "Українська (Ukrainian)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0196_merge_20240318_1737.py b/bookwyrm/migrations/0196_merge_20240318_1737.py
new file mode 100644
index 000000000..2d80b2e58
--- /dev/null
+++ b/bookwyrm/migrations/0196_merge_20240318_1737.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2024-03-18 17:37
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0193_auto_20240128_0249"),
+ ("bookwyrm", "0195_alter_user_preferred_language"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0196_merge_pr3134_into_main.py b/bookwyrm/migrations/0196_merge_pr3134_into_main.py
new file mode 100644
index 000000000..0862f11b2
--- /dev/null
+++ b/bookwyrm/migrations/0196_merge_pr3134_into_main.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.23 on 2024-03-18 00:48
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0191_migrate_search_vec_triggers_to_pgtriggers"),
+ ("bookwyrm", "0195_alter_user_preferred_language"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0197_author_search_vector.py b/bookwyrm/migrations/0197_author_search_vector.py
new file mode 100644
index 000000000..baa540cc0
--- /dev/null
+++ b/bookwyrm/migrations/0197_author_search_vector.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.2.25 on 2024-03-20 15:15
+
+import django.contrib.postgres.indexes
+from django.db import migrations
+import pgtrigger.compiler
+import pgtrigger.migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0196_merge_pr3134_into_main"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="author",
+ index=django.contrib.postgres.indexes.GinIndex(
+ fields=["search_vector"], name="bookwyrm_au_search__b050a8_gin"
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="author",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_search_vector_on_author_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="new.search_vector := setweight(to_tsvector('simple', new.name), 'A') || setweight(to_tsvector('simple', coalesce(array_to_string(new.aliases, ' '), '')), 'B');RETURN NEW;",
+ hash="b97919016236d74d0ade51a0769a173ea269da64",
+ operation='INSERT OR UPDATE OF "name", "aliases", "search_vector"',
+ pgid="pgtrigger_update_search_vector_on_author_edit_c61cb",
+ table="bookwyrm_author",
+ when="BEFORE",
+ ),
+ ),
+ ),
+ migrations.RunSQL(
+ # Calculate search vector for all Authors.
+ sql="UPDATE bookwyrm_author SET search_vector = NULL;",
+ reverse_sql="UPDATE bookwyrm_author SET search_vector = NULL;",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0197_merge_20240324_0235.py b/bookwyrm/migrations/0197_merge_20240324_0235.py
new file mode 100644
index 000000000..a7c01a955
--- /dev/null
+++ b/bookwyrm/migrations/0197_merge_20240324_0235.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-03-24 02:35
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0196_merge_20240318_1737"),
+ ("bookwyrm", "0196_merge_pr3134_into_main"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0197_mergedauthor_mergedbook.py b/bookwyrm/migrations/0197_mergedauthor_mergedbook.py
new file mode 100644
index 000000000..23ca38ab2
--- /dev/null
+++ b/bookwyrm/migrations/0197_mergedauthor_mergedbook.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.2.24 on 2024-02-28 21:30
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0196_merge_pr3134_into_main"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="MergedBook",
+ fields=[
+ ("deleted_id", models.IntegerField(primary_key=True, serialize=False)),
+ (
+ "merged_into",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="absorbed",
+ to="bookwyrm.book",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.CreateModel(
+ name="MergedAuthor",
+ fields=[
+ ("deleted_id", models.IntegerField(primary_key=True, serialize=False)),
+ (
+ "merged_into",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="absorbed",
+ to="bookwyrm.author",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py
new file mode 100644
index 000000000..552584d2b
--- /dev/null
+++ b/bookwyrm/migrations/0198_alter_bookwyrmexportjob_export_data.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.25 on 2024-03-26 11:37
+
+import bookwyrm.models.bookwyrm_export_job
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0197_merge_20240324_0235"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="bookwyrmexportjob",
+ name="export_data",
+ field=models.FileField(
+ null=True,
+ storage=bookwyrm.models.bookwyrm_export_job.select_exports_storage,
+ upload_to="",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0198_book_search_vector_author_aliases.py b/bookwyrm/migrations/0198_book_search_vector_author_aliases.py
new file mode 100644
index 000000000..491cb64bb
--- /dev/null
+++ b/bookwyrm/migrations/0198_book_search_vector_author_aliases.py
@@ -0,0 +1,57 @@
+# Generated by Django 3.2.25 on 2024-03-20 15:52
+
+from django.db import migrations
+import pgtrigger.compiler
+import pgtrigger.migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0197_author_search_vector"),
+ ]
+
+ operations = [
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="author",
+ name="reset_search_vector_on_author_edit",
+ ),
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="book",
+ name="update_search_vector_on_book_edit",
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="author",
+ trigger=pgtrigger.compiler.Trigger(
+ name="reset_book_search_vector_on_author_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="WITH updated_books AS (SELECT book_id FROM bookwyrm_book_authors WHERE author_id = new.id ) UPDATE bookwyrm_book SET search_vector = '' FROM updated_books WHERE id = updated_books.book_id;RETURN NEW;",
+ hash="68422c0f29879c5802b82159dde45297eff53e73",
+ operation='UPDATE OF "name", "aliases"',
+ pgid="pgtrigger_reset_book_search_vector_on_author_edit_a50c7",
+ table="bookwyrm_author",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="book",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_search_vector_on_book_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="WITH author_names AS (SELECT array_to_string(bookwyrm_author.name || bookwyrm_author.aliases, ' ') AS name_and_aliases FROM bookwyrm_author LEFT JOIN bookwyrm_book_authors ON bookwyrm_author.id = bookwyrm_book_authors.author_id WHERE bookwyrm_book_authors.book_id = new.id ) SELECT setweight(coalesce(nullif(to_tsvector('english', new.title), ''), to_tsvector('simple', new.title)), 'A') || setweight(to_tsvector('english', coalesce(new.subtitle, '')), 'B') || (SELECT setweight(to_tsvector('simple', coalesce(array_to_string(array_agg(name_and_aliases), ' '), '')), 'C') FROM author_names) || setweight(to_tsvector('english', coalesce(new.series, '')), 'D') INTO new.search_vector;RETURN NEW;",
+ hash="9324f5ca76a6f5e63931881d62d11da11f595b2c",
+ operation='INSERT OR UPDATE OF "title", "subtitle", "series", "search_vector"',
+ pgid="pgtrigger_update_search_vector_on_book_edit_bec58",
+ table="bookwyrm_book",
+ when="BEFORE",
+ ),
+ ),
+ ),
+ migrations.RunSQL(
+ # Recalculate search vector for all Books because it now includes
+ # Author aliases.
+ sql="UPDATE bookwyrm_book SET search_vector = NULL;",
+ reverse_sql="UPDATE bookwyrm_book SET search_vector = NULL;",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0199_alter_userblocks_user_object_and_more.py b/bookwyrm/migrations/0199_alter_userblocks_user_object_and_more.py
new file mode 100644
index 000000000..bde1f25c1
--- /dev/null
+++ b/bookwyrm/migrations/0199_alter_userblocks_user_object_and_more.py
@@ -0,0 +1,70 @@
+# Generated by Django 4.2.11 on 2024-03-29 19:25
+
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0198_book_search_vector_author_aliases"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="userblocks",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userblocks",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollowrequest",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollowrequest",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollows",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="userfollows",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0199_merge_20240326_1217.py b/bookwyrm/migrations/0199_merge_20240326_1217.py
new file mode 100644
index 000000000..7794af54a
--- /dev/null
+++ b/bookwyrm/migrations/0199_merge_20240326_1217.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-03-26 12:17
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0198_alter_bookwyrmexportjob_export_data"),
+ ("bookwyrm", "0198_book_search_vector_author_aliases"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0199_status_bookwyrm_st_remote__06aeba_idx.py b/bookwyrm/migrations/0199_status_bookwyrm_st_remote__06aeba_idx.py
new file mode 100644
index 000000000..5d2513698
--- /dev/null
+++ b/bookwyrm/migrations/0199_status_bookwyrm_st_remote__06aeba_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-02 19:53
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0198_book_search_vector_author_aliases"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="status",
+ index=models.Index(
+ fields=["remote_id"], name="bookwyrm_st_remote__06aeba_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0200_alter_user_preferred_timezone.py b/bookwyrm/migrations/0200_alter_user_preferred_timezone.py
new file mode 100644
index 000000000..1b21c0f94
--- /dev/null
+++ b/bookwyrm/migrations/0200_alter_user_preferred_timezone.py
@@ -0,0 +1,633 @@
+# Generated by Django 4.2.11 on 2024-04-01 20:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0199_alter_userblocks_user_object_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_timezone",
+ field=models.CharField(
+ choices=[
+ ("Africa/Abidjan", "Africa/Abidjan"),
+ ("Africa/Accra", "Africa/Accra"),
+ ("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
+ ("Africa/Algiers", "Africa/Algiers"),
+ ("Africa/Asmara", "Africa/Asmara"),
+ ("Africa/Asmera", "Africa/Asmera"),
+ ("Africa/Bamako", "Africa/Bamako"),
+ ("Africa/Bangui", "Africa/Bangui"),
+ ("Africa/Banjul", "Africa/Banjul"),
+ ("Africa/Bissau", "Africa/Bissau"),
+ ("Africa/Blantyre", "Africa/Blantyre"),
+ ("Africa/Brazzaville", "Africa/Brazzaville"),
+ ("Africa/Bujumbura", "Africa/Bujumbura"),
+ ("Africa/Cairo", "Africa/Cairo"),
+ ("Africa/Casablanca", "Africa/Casablanca"),
+ ("Africa/Ceuta", "Africa/Ceuta"),
+ ("Africa/Conakry", "Africa/Conakry"),
+ ("Africa/Dakar", "Africa/Dakar"),
+ ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
+ ("Africa/Djibouti", "Africa/Djibouti"),
+ ("Africa/Douala", "Africa/Douala"),
+ ("Africa/El_Aaiun", "Africa/El_Aaiun"),
+ ("Africa/Freetown", "Africa/Freetown"),
+ ("Africa/Gaborone", "Africa/Gaborone"),
+ ("Africa/Harare", "Africa/Harare"),
+ ("Africa/Johannesburg", "Africa/Johannesburg"),
+ ("Africa/Juba", "Africa/Juba"),
+ ("Africa/Kampala", "Africa/Kampala"),
+ ("Africa/Khartoum", "Africa/Khartoum"),
+ ("Africa/Kigali", "Africa/Kigali"),
+ ("Africa/Kinshasa", "Africa/Kinshasa"),
+ ("Africa/Lagos", "Africa/Lagos"),
+ ("Africa/Libreville", "Africa/Libreville"),
+ ("Africa/Lome", "Africa/Lome"),
+ ("Africa/Luanda", "Africa/Luanda"),
+ ("Africa/Lubumbashi", "Africa/Lubumbashi"),
+ ("Africa/Lusaka", "Africa/Lusaka"),
+ ("Africa/Malabo", "Africa/Malabo"),
+ ("Africa/Maputo", "Africa/Maputo"),
+ ("Africa/Maseru", "Africa/Maseru"),
+ ("Africa/Mbabane", "Africa/Mbabane"),
+ ("Africa/Mogadishu", "Africa/Mogadishu"),
+ ("Africa/Monrovia", "Africa/Monrovia"),
+ ("Africa/Nairobi", "Africa/Nairobi"),
+ ("Africa/Ndjamena", "Africa/Ndjamena"),
+ ("Africa/Niamey", "Africa/Niamey"),
+ ("Africa/Nouakchott", "Africa/Nouakchott"),
+ ("Africa/Ouagadougou", "Africa/Ouagadougou"),
+ ("Africa/Porto-Novo", "Africa/Porto-Novo"),
+ ("Africa/Sao_Tome", "Africa/Sao_Tome"),
+ ("Africa/Timbuktu", "Africa/Timbuktu"),
+ ("Africa/Tripoli", "Africa/Tripoli"),
+ ("Africa/Tunis", "Africa/Tunis"),
+ ("Africa/Windhoek", "Africa/Windhoek"),
+ ("America/Adak", "America/Adak"),
+ ("America/Anchorage", "America/Anchorage"),
+ ("America/Anguilla", "America/Anguilla"),
+ ("America/Antigua", "America/Antigua"),
+ ("America/Araguaina", "America/Araguaina"),
+ (
+ "America/Argentina/Buenos_Aires",
+ "America/Argentina/Buenos_Aires",
+ ),
+ ("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
+ (
+ "America/Argentina/ComodRivadavia",
+ "America/Argentina/ComodRivadavia",
+ ),
+ ("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
+ ("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
+ ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
+ ("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
+ (
+ "America/Argentina/Rio_Gallegos",
+ "America/Argentina/Rio_Gallegos",
+ ),
+ ("America/Argentina/Salta", "America/Argentina/Salta"),
+ ("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
+ ("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
+ ("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
+ ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
+ ("America/Aruba", "America/Aruba"),
+ ("America/Asuncion", "America/Asuncion"),
+ ("America/Atikokan", "America/Atikokan"),
+ ("America/Atka", "America/Atka"),
+ ("America/Bahia", "America/Bahia"),
+ ("America/Bahia_Banderas", "America/Bahia_Banderas"),
+ ("America/Barbados", "America/Barbados"),
+ ("America/Belem", "America/Belem"),
+ ("America/Belize", "America/Belize"),
+ ("America/Blanc-Sablon", "America/Blanc-Sablon"),
+ ("America/Boa_Vista", "America/Boa_Vista"),
+ ("America/Bogota", "America/Bogota"),
+ ("America/Boise", "America/Boise"),
+ ("America/Buenos_Aires", "America/Buenos_Aires"),
+ ("America/Cambridge_Bay", "America/Cambridge_Bay"),
+ ("America/Campo_Grande", "America/Campo_Grande"),
+ ("America/Cancun", "America/Cancun"),
+ ("America/Caracas", "America/Caracas"),
+ ("America/Catamarca", "America/Catamarca"),
+ ("America/Cayenne", "America/Cayenne"),
+ ("America/Cayman", "America/Cayman"),
+ ("America/Chicago", "America/Chicago"),
+ ("America/Chihuahua", "America/Chihuahua"),
+ ("America/Ciudad_Juarez", "America/Ciudad_Juarez"),
+ ("America/Coral_Harbour", "America/Coral_Harbour"),
+ ("America/Cordoba", "America/Cordoba"),
+ ("America/Costa_Rica", "America/Costa_Rica"),
+ ("America/Creston", "America/Creston"),
+ ("America/Cuiaba", "America/Cuiaba"),
+ ("America/Curacao", "America/Curacao"),
+ ("America/Danmarkshavn", "America/Danmarkshavn"),
+ ("America/Dawson", "America/Dawson"),
+ ("America/Dawson_Creek", "America/Dawson_Creek"),
+ ("America/Denver", "America/Denver"),
+ ("America/Detroit", "America/Detroit"),
+ ("America/Dominica", "America/Dominica"),
+ ("America/Edmonton", "America/Edmonton"),
+ ("America/Eirunepe", "America/Eirunepe"),
+ ("America/El_Salvador", "America/El_Salvador"),
+ ("America/Ensenada", "America/Ensenada"),
+ ("America/Fort_Nelson", "America/Fort_Nelson"),
+ ("America/Fort_Wayne", "America/Fort_Wayne"),
+ ("America/Fortaleza", "America/Fortaleza"),
+ ("America/Glace_Bay", "America/Glace_Bay"),
+ ("America/Godthab", "America/Godthab"),
+ ("America/Goose_Bay", "America/Goose_Bay"),
+ ("America/Grand_Turk", "America/Grand_Turk"),
+ ("America/Grenada", "America/Grenada"),
+ ("America/Guadeloupe", "America/Guadeloupe"),
+ ("America/Guatemala", "America/Guatemala"),
+ ("America/Guayaquil", "America/Guayaquil"),
+ ("America/Guyana", "America/Guyana"),
+ ("America/Halifax", "America/Halifax"),
+ ("America/Havana", "America/Havana"),
+ ("America/Hermosillo", "America/Hermosillo"),
+ ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
+ ("America/Indiana/Knox", "America/Indiana/Knox"),
+ ("America/Indiana/Marengo", "America/Indiana/Marengo"),
+ ("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
+ ("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
+ ("America/Indiana/Vevay", "America/Indiana/Vevay"),
+ ("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
+ ("America/Indiana/Winamac", "America/Indiana/Winamac"),
+ ("America/Indianapolis", "America/Indianapolis"),
+ ("America/Inuvik", "America/Inuvik"),
+ ("America/Iqaluit", "America/Iqaluit"),
+ ("America/Jamaica", "America/Jamaica"),
+ ("America/Jujuy", "America/Jujuy"),
+ ("America/Juneau", "America/Juneau"),
+ ("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
+ ("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
+ ("America/Knox_IN", "America/Knox_IN"),
+ ("America/Kralendijk", "America/Kralendijk"),
+ ("America/La_Paz", "America/La_Paz"),
+ ("America/Lima", "America/Lima"),
+ ("America/Los_Angeles", "America/Los_Angeles"),
+ ("America/Louisville", "America/Louisville"),
+ ("America/Lower_Princes", "America/Lower_Princes"),
+ ("America/Maceio", "America/Maceio"),
+ ("America/Managua", "America/Managua"),
+ ("America/Manaus", "America/Manaus"),
+ ("America/Marigot", "America/Marigot"),
+ ("America/Martinique", "America/Martinique"),
+ ("America/Matamoros", "America/Matamoros"),
+ ("America/Mazatlan", "America/Mazatlan"),
+ ("America/Mendoza", "America/Mendoza"),
+ ("America/Menominee", "America/Menominee"),
+ ("America/Merida", "America/Merida"),
+ ("America/Metlakatla", "America/Metlakatla"),
+ ("America/Mexico_City", "America/Mexico_City"),
+ ("America/Miquelon", "America/Miquelon"),
+ ("America/Moncton", "America/Moncton"),
+ ("America/Monterrey", "America/Monterrey"),
+ ("America/Montevideo", "America/Montevideo"),
+ ("America/Montreal", "America/Montreal"),
+ ("America/Montserrat", "America/Montserrat"),
+ ("America/Nassau", "America/Nassau"),
+ ("America/New_York", "America/New_York"),
+ ("America/Nipigon", "America/Nipigon"),
+ ("America/Nome", "America/Nome"),
+ ("America/Noronha", "America/Noronha"),
+ ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
+ ("America/North_Dakota/Center", "America/North_Dakota/Center"),
+ (
+ "America/North_Dakota/New_Salem",
+ "America/North_Dakota/New_Salem",
+ ),
+ ("America/Nuuk", "America/Nuuk"),
+ ("America/Ojinaga", "America/Ojinaga"),
+ ("America/Panama", "America/Panama"),
+ ("America/Pangnirtung", "America/Pangnirtung"),
+ ("America/Paramaribo", "America/Paramaribo"),
+ ("America/Phoenix", "America/Phoenix"),
+ ("America/Port-au-Prince", "America/Port-au-Prince"),
+ ("America/Port_of_Spain", "America/Port_of_Spain"),
+ ("America/Porto_Acre", "America/Porto_Acre"),
+ ("America/Porto_Velho", "America/Porto_Velho"),
+ ("America/Puerto_Rico", "America/Puerto_Rico"),
+ ("America/Punta_Arenas", "America/Punta_Arenas"),
+ ("America/Rainy_River", "America/Rainy_River"),
+ ("America/Rankin_Inlet", "America/Rankin_Inlet"),
+ ("America/Recife", "America/Recife"),
+ ("America/Regina", "America/Regina"),
+ ("America/Resolute", "America/Resolute"),
+ ("America/Rio_Branco", "America/Rio_Branco"),
+ ("America/Rosario", "America/Rosario"),
+ ("America/Santa_Isabel", "America/Santa_Isabel"),
+ ("America/Santarem", "America/Santarem"),
+ ("America/Santiago", "America/Santiago"),
+ ("America/Santo_Domingo", "America/Santo_Domingo"),
+ ("America/Sao_Paulo", "America/Sao_Paulo"),
+ ("America/Scoresbysund", "America/Scoresbysund"),
+ ("America/Shiprock", "America/Shiprock"),
+ ("America/Sitka", "America/Sitka"),
+ ("America/St_Barthelemy", "America/St_Barthelemy"),
+ ("America/St_Johns", "America/St_Johns"),
+ ("America/St_Kitts", "America/St_Kitts"),
+ ("America/St_Lucia", "America/St_Lucia"),
+ ("America/St_Thomas", "America/St_Thomas"),
+ ("America/St_Vincent", "America/St_Vincent"),
+ ("America/Swift_Current", "America/Swift_Current"),
+ ("America/Tegucigalpa", "America/Tegucigalpa"),
+ ("America/Thule", "America/Thule"),
+ ("America/Thunder_Bay", "America/Thunder_Bay"),
+ ("America/Tijuana", "America/Tijuana"),
+ ("America/Toronto", "America/Toronto"),
+ ("America/Tortola", "America/Tortola"),
+ ("America/Vancouver", "America/Vancouver"),
+ ("America/Virgin", "America/Virgin"),
+ ("America/Whitehorse", "America/Whitehorse"),
+ ("America/Winnipeg", "America/Winnipeg"),
+ ("America/Yakutat", "America/Yakutat"),
+ ("America/Yellowknife", "America/Yellowknife"),
+ ("Antarctica/Casey", "Antarctica/Casey"),
+ ("Antarctica/Davis", "Antarctica/Davis"),
+ ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
+ ("Antarctica/Macquarie", "Antarctica/Macquarie"),
+ ("Antarctica/Mawson", "Antarctica/Mawson"),
+ ("Antarctica/McMurdo", "Antarctica/McMurdo"),
+ ("Antarctica/Palmer", "Antarctica/Palmer"),
+ ("Antarctica/Rothera", "Antarctica/Rothera"),
+ ("Antarctica/South_Pole", "Antarctica/South_Pole"),
+ ("Antarctica/Syowa", "Antarctica/Syowa"),
+ ("Antarctica/Troll", "Antarctica/Troll"),
+ ("Antarctica/Vostok", "Antarctica/Vostok"),
+ ("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
+ ("Asia/Aden", "Asia/Aden"),
+ ("Asia/Almaty", "Asia/Almaty"),
+ ("Asia/Amman", "Asia/Amman"),
+ ("Asia/Anadyr", "Asia/Anadyr"),
+ ("Asia/Aqtau", "Asia/Aqtau"),
+ ("Asia/Aqtobe", "Asia/Aqtobe"),
+ ("Asia/Ashgabat", "Asia/Ashgabat"),
+ ("Asia/Ashkhabad", "Asia/Ashkhabad"),
+ ("Asia/Atyrau", "Asia/Atyrau"),
+ ("Asia/Baghdad", "Asia/Baghdad"),
+ ("Asia/Bahrain", "Asia/Bahrain"),
+ ("Asia/Baku", "Asia/Baku"),
+ ("Asia/Bangkok", "Asia/Bangkok"),
+ ("Asia/Barnaul", "Asia/Barnaul"),
+ ("Asia/Beirut", "Asia/Beirut"),
+ ("Asia/Bishkek", "Asia/Bishkek"),
+ ("Asia/Brunei", "Asia/Brunei"),
+ ("Asia/Calcutta", "Asia/Calcutta"),
+ ("Asia/Chita", "Asia/Chita"),
+ ("Asia/Choibalsan", "Asia/Choibalsan"),
+ ("Asia/Chongqing", "Asia/Chongqing"),
+ ("Asia/Chungking", "Asia/Chungking"),
+ ("Asia/Colombo", "Asia/Colombo"),
+ ("Asia/Dacca", "Asia/Dacca"),
+ ("Asia/Damascus", "Asia/Damascus"),
+ ("Asia/Dhaka", "Asia/Dhaka"),
+ ("Asia/Dili", "Asia/Dili"),
+ ("Asia/Dubai", "Asia/Dubai"),
+ ("Asia/Dushanbe", "Asia/Dushanbe"),
+ ("Asia/Famagusta", "Asia/Famagusta"),
+ ("Asia/Gaza", "Asia/Gaza"),
+ ("Asia/Harbin", "Asia/Harbin"),
+ ("Asia/Hebron", "Asia/Hebron"),
+ ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
+ ("Asia/Hong_Kong", "Asia/Hong_Kong"),
+ ("Asia/Hovd", "Asia/Hovd"),
+ ("Asia/Irkutsk", "Asia/Irkutsk"),
+ ("Asia/Istanbul", "Asia/Istanbul"),
+ ("Asia/Jakarta", "Asia/Jakarta"),
+ ("Asia/Jayapura", "Asia/Jayapura"),
+ ("Asia/Jerusalem", "Asia/Jerusalem"),
+ ("Asia/Kabul", "Asia/Kabul"),
+ ("Asia/Kamchatka", "Asia/Kamchatka"),
+ ("Asia/Karachi", "Asia/Karachi"),
+ ("Asia/Kashgar", "Asia/Kashgar"),
+ ("Asia/Kathmandu", "Asia/Kathmandu"),
+ ("Asia/Katmandu", "Asia/Katmandu"),
+ ("Asia/Khandyga", "Asia/Khandyga"),
+ ("Asia/Kolkata", "Asia/Kolkata"),
+ ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
+ ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
+ ("Asia/Kuching", "Asia/Kuching"),
+ ("Asia/Kuwait", "Asia/Kuwait"),
+ ("Asia/Macao", "Asia/Macao"),
+ ("Asia/Macau", "Asia/Macau"),
+ ("Asia/Magadan", "Asia/Magadan"),
+ ("Asia/Makassar", "Asia/Makassar"),
+ ("Asia/Manila", "Asia/Manila"),
+ ("Asia/Muscat", "Asia/Muscat"),
+ ("Asia/Nicosia", "Asia/Nicosia"),
+ ("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
+ ("Asia/Novosibirsk", "Asia/Novosibirsk"),
+ ("Asia/Omsk", "Asia/Omsk"),
+ ("Asia/Oral", "Asia/Oral"),
+ ("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
+ ("Asia/Pontianak", "Asia/Pontianak"),
+ ("Asia/Pyongyang", "Asia/Pyongyang"),
+ ("Asia/Qatar", "Asia/Qatar"),
+ ("Asia/Qostanay", "Asia/Qostanay"),
+ ("Asia/Qyzylorda", "Asia/Qyzylorda"),
+ ("Asia/Rangoon", "Asia/Rangoon"),
+ ("Asia/Riyadh", "Asia/Riyadh"),
+ ("Asia/Saigon", "Asia/Saigon"),
+ ("Asia/Sakhalin", "Asia/Sakhalin"),
+ ("Asia/Samarkand", "Asia/Samarkand"),
+ ("Asia/Seoul", "Asia/Seoul"),
+ ("Asia/Shanghai", "Asia/Shanghai"),
+ ("Asia/Singapore", "Asia/Singapore"),
+ ("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
+ ("Asia/Taipei", "Asia/Taipei"),
+ ("Asia/Tashkent", "Asia/Tashkent"),
+ ("Asia/Tbilisi", "Asia/Tbilisi"),
+ ("Asia/Tehran", "Asia/Tehran"),
+ ("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
+ ("Asia/Thimbu", "Asia/Thimbu"),
+ ("Asia/Thimphu", "Asia/Thimphu"),
+ ("Asia/Tokyo", "Asia/Tokyo"),
+ ("Asia/Tomsk", "Asia/Tomsk"),
+ ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
+ ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
+ ("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
+ ("Asia/Urumqi", "Asia/Urumqi"),
+ ("Asia/Ust-Nera", "Asia/Ust-Nera"),
+ ("Asia/Vientiane", "Asia/Vientiane"),
+ ("Asia/Vladivostok", "Asia/Vladivostok"),
+ ("Asia/Yakutsk", "Asia/Yakutsk"),
+ ("Asia/Yangon", "Asia/Yangon"),
+ ("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
+ ("Asia/Yerevan", "Asia/Yerevan"),
+ ("Atlantic/Azores", "Atlantic/Azores"),
+ ("Atlantic/Bermuda", "Atlantic/Bermuda"),
+ ("Atlantic/Canary", "Atlantic/Canary"),
+ ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
+ ("Atlantic/Faeroe", "Atlantic/Faeroe"),
+ ("Atlantic/Faroe", "Atlantic/Faroe"),
+ ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
+ ("Atlantic/Madeira", "Atlantic/Madeira"),
+ ("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
+ ("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
+ ("Atlantic/St_Helena", "Atlantic/St_Helena"),
+ ("Atlantic/Stanley", "Atlantic/Stanley"),
+ ("Australia/ACT", "Australia/ACT"),
+ ("Australia/Adelaide", "Australia/Adelaide"),
+ ("Australia/Brisbane", "Australia/Brisbane"),
+ ("Australia/Broken_Hill", "Australia/Broken_Hill"),
+ ("Australia/Canberra", "Australia/Canberra"),
+ ("Australia/Currie", "Australia/Currie"),
+ ("Australia/Darwin", "Australia/Darwin"),
+ ("Australia/Eucla", "Australia/Eucla"),
+ ("Australia/Hobart", "Australia/Hobart"),
+ ("Australia/LHI", "Australia/LHI"),
+ ("Australia/Lindeman", "Australia/Lindeman"),
+ ("Australia/Lord_Howe", "Australia/Lord_Howe"),
+ ("Australia/Melbourne", "Australia/Melbourne"),
+ ("Australia/NSW", "Australia/NSW"),
+ ("Australia/North", "Australia/North"),
+ ("Australia/Perth", "Australia/Perth"),
+ ("Australia/Queensland", "Australia/Queensland"),
+ ("Australia/South", "Australia/South"),
+ ("Australia/Sydney", "Australia/Sydney"),
+ ("Australia/Tasmania", "Australia/Tasmania"),
+ ("Australia/Victoria", "Australia/Victoria"),
+ ("Australia/West", "Australia/West"),
+ ("Australia/Yancowinna", "Australia/Yancowinna"),
+ ("Brazil/Acre", "Brazil/Acre"),
+ ("Brazil/DeNoronha", "Brazil/DeNoronha"),
+ ("Brazil/East", "Brazil/East"),
+ ("Brazil/West", "Brazil/West"),
+ ("CET", "CET"),
+ ("CST6CDT", "CST6CDT"),
+ ("Canada/Atlantic", "Canada/Atlantic"),
+ ("Canada/Central", "Canada/Central"),
+ ("Canada/Eastern", "Canada/Eastern"),
+ ("Canada/Mountain", "Canada/Mountain"),
+ ("Canada/Newfoundland", "Canada/Newfoundland"),
+ ("Canada/Pacific", "Canada/Pacific"),
+ ("Canada/Saskatchewan", "Canada/Saskatchewan"),
+ ("Canada/Yukon", "Canada/Yukon"),
+ ("Chile/Continental", "Chile/Continental"),
+ ("Chile/EasterIsland", "Chile/EasterIsland"),
+ ("Cuba", "Cuba"),
+ ("EET", "EET"),
+ ("EST", "EST"),
+ ("EST5EDT", "EST5EDT"),
+ ("Egypt", "Egypt"),
+ ("Eire", "Eire"),
+ ("Etc/GMT", "Etc/GMT"),
+ ("Etc/GMT+0", "Etc/GMT+0"),
+ ("Etc/GMT+1", "Etc/GMT+1"),
+ ("Etc/GMT+10", "Etc/GMT+10"),
+ ("Etc/GMT+11", "Etc/GMT+11"),
+ ("Etc/GMT+12", "Etc/GMT+12"),
+ ("Etc/GMT+2", "Etc/GMT+2"),
+ ("Etc/GMT+3", "Etc/GMT+3"),
+ ("Etc/GMT+4", "Etc/GMT+4"),
+ ("Etc/GMT+5", "Etc/GMT+5"),
+ ("Etc/GMT+6", "Etc/GMT+6"),
+ ("Etc/GMT+7", "Etc/GMT+7"),
+ ("Etc/GMT+8", "Etc/GMT+8"),
+ ("Etc/GMT+9", "Etc/GMT+9"),
+ ("Etc/GMT-0", "Etc/GMT-0"),
+ ("Etc/GMT-1", "Etc/GMT-1"),
+ ("Etc/GMT-10", "Etc/GMT-10"),
+ ("Etc/GMT-11", "Etc/GMT-11"),
+ ("Etc/GMT-12", "Etc/GMT-12"),
+ ("Etc/GMT-13", "Etc/GMT-13"),
+ ("Etc/GMT-14", "Etc/GMT-14"),
+ ("Etc/GMT-2", "Etc/GMT-2"),
+ ("Etc/GMT-3", "Etc/GMT-3"),
+ ("Etc/GMT-4", "Etc/GMT-4"),
+ ("Etc/GMT-5", "Etc/GMT-5"),
+ ("Etc/GMT-6", "Etc/GMT-6"),
+ ("Etc/GMT-7", "Etc/GMT-7"),
+ ("Etc/GMT-8", "Etc/GMT-8"),
+ ("Etc/GMT-9", "Etc/GMT-9"),
+ ("Etc/GMT0", "Etc/GMT0"),
+ ("Etc/Greenwich", "Etc/Greenwich"),
+ ("Etc/UCT", "Etc/UCT"),
+ ("Etc/UTC", "Etc/UTC"),
+ ("Etc/Universal", "Etc/Universal"),
+ ("Etc/Zulu", "Etc/Zulu"),
+ ("Europe/Amsterdam", "Europe/Amsterdam"),
+ ("Europe/Andorra", "Europe/Andorra"),
+ ("Europe/Astrakhan", "Europe/Astrakhan"),
+ ("Europe/Athens", "Europe/Athens"),
+ ("Europe/Belfast", "Europe/Belfast"),
+ ("Europe/Belgrade", "Europe/Belgrade"),
+ ("Europe/Berlin", "Europe/Berlin"),
+ ("Europe/Bratislava", "Europe/Bratislava"),
+ ("Europe/Brussels", "Europe/Brussels"),
+ ("Europe/Bucharest", "Europe/Bucharest"),
+ ("Europe/Budapest", "Europe/Budapest"),
+ ("Europe/Busingen", "Europe/Busingen"),
+ ("Europe/Chisinau", "Europe/Chisinau"),
+ ("Europe/Copenhagen", "Europe/Copenhagen"),
+ ("Europe/Dublin", "Europe/Dublin"),
+ ("Europe/Gibraltar", "Europe/Gibraltar"),
+ ("Europe/Guernsey", "Europe/Guernsey"),
+ ("Europe/Helsinki", "Europe/Helsinki"),
+ ("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
+ ("Europe/Istanbul", "Europe/Istanbul"),
+ ("Europe/Jersey", "Europe/Jersey"),
+ ("Europe/Kaliningrad", "Europe/Kaliningrad"),
+ ("Europe/Kiev", "Europe/Kiev"),
+ ("Europe/Kirov", "Europe/Kirov"),
+ ("Europe/Kyiv", "Europe/Kyiv"),
+ ("Europe/Lisbon", "Europe/Lisbon"),
+ ("Europe/Ljubljana", "Europe/Ljubljana"),
+ ("Europe/London", "Europe/London"),
+ ("Europe/Luxembourg", "Europe/Luxembourg"),
+ ("Europe/Madrid", "Europe/Madrid"),
+ ("Europe/Malta", "Europe/Malta"),
+ ("Europe/Mariehamn", "Europe/Mariehamn"),
+ ("Europe/Minsk", "Europe/Minsk"),
+ ("Europe/Monaco", "Europe/Monaco"),
+ ("Europe/Moscow", "Europe/Moscow"),
+ ("Europe/Nicosia", "Europe/Nicosia"),
+ ("Europe/Oslo", "Europe/Oslo"),
+ ("Europe/Paris", "Europe/Paris"),
+ ("Europe/Podgorica", "Europe/Podgorica"),
+ ("Europe/Prague", "Europe/Prague"),
+ ("Europe/Riga", "Europe/Riga"),
+ ("Europe/Rome", "Europe/Rome"),
+ ("Europe/Samara", "Europe/Samara"),
+ ("Europe/San_Marino", "Europe/San_Marino"),
+ ("Europe/Sarajevo", "Europe/Sarajevo"),
+ ("Europe/Saratov", "Europe/Saratov"),
+ ("Europe/Simferopol", "Europe/Simferopol"),
+ ("Europe/Skopje", "Europe/Skopje"),
+ ("Europe/Sofia", "Europe/Sofia"),
+ ("Europe/Stockholm", "Europe/Stockholm"),
+ ("Europe/Tallinn", "Europe/Tallinn"),
+ ("Europe/Tirane", "Europe/Tirane"),
+ ("Europe/Tiraspol", "Europe/Tiraspol"),
+ ("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
+ ("Europe/Uzhgorod", "Europe/Uzhgorod"),
+ ("Europe/Vaduz", "Europe/Vaduz"),
+ ("Europe/Vatican", "Europe/Vatican"),
+ ("Europe/Vienna", "Europe/Vienna"),
+ ("Europe/Vilnius", "Europe/Vilnius"),
+ ("Europe/Volgograd", "Europe/Volgograd"),
+ ("Europe/Warsaw", "Europe/Warsaw"),
+ ("Europe/Zagreb", "Europe/Zagreb"),
+ ("Europe/Zaporozhye", "Europe/Zaporozhye"),
+ ("Europe/Zurich", "Europe/Zurich"),
+ ("Factory", "Factory"),
+ ("GB", "GB"),
+ ("GB-Eire", "GB-Eire"),
+ ("GMT", "GMT"),
+ ("GMT+0", "GMT+0"),
+ ("GMT-0", "GMT-0"),
+ ("GMT0", "GMT0"),
+ ("Greenwich", "Greenwich"),
+ ("HST", "HST"),
+ ("Hongkong", "Hongkong"),
+ ("Iceland", "Iceland"),
+ ("Indian/Antananarivo", "Indian/Antananarivo"),
+ ("Indian/Chagos", "Indian/Chagos"),
+ ("Indian/Christmas", "Indian/Christmas"),
+ ("Indian/Cocos", "Indian/Cocos"),
+ ("Indian/Comoro", "Indian/Comoro"),
+ ("Indian/Kerguelen", "Indian/Kerguelen"),
+ ("Indian/Mahe", "Indian/Mahe"),
+ ("Indian/Maldives", "Indian/Maldives"),
+ ("Indian/Mauritius", "Indian/Mauritius"),
+ ("Indian/Mayotte", "Indian/Mayotte"),
+ ("Indian/Reunion", "Indian/Reunion"),
+ ("Iran", "Iran"),
+ ("Israel", "Israel"),
+ ("Jamaica", "Jamaica"),
+ ("Japan", "Japan"),
+ ("Kwajalein", "Kwajalein"),
+ ("Libya", "Libya"),
+ ("MET", "MET"),
+ ("MST", "MST"),
+ ("MST7MDT", "MST7MDT"),
+ ("Mexico/BajaNorte", "Mexico/BajaNorte"),
+ ("Mexico/BajaSur", "Mexico/BajaSur"),
+ ("Mexico/General", "Mexico/General"),
+ ("NZ", "NZ"),
+ ("NZ-CHAT", "NZ-CHAT"),
+ ("Navajo", "Navajo"),
+ ("PRC", "PRC"),
+ ("PST8PDT", "PST8PDT"),
+ ("Pacific/Apia", "Pacific/Apia"),
+ ("Pacific/Auckland", "Pacific/Auckland"),
+ ("Pacific/Bougainville", "Pacific/Bougainville"),
+ ("Pacific/Chatham", "Pacific/Chatham"),
+ ("Pacific/Chuuk", "Pacific/Chuuk"),
+ ("Pacific/Easter", "Pacific/Easter"),
+ ("Pacific/Efate", "Pacific/Efate"),
+ ("Pacific/Enderbury", "Pacific/Enderbury"),
+ ("Pacific/Fakaofo", "Pacific/Fakaofo"),
+ ("Pacific/Fiji", "Pacific/Fiji"),
+ ("Pacific/Funafuti", "Pacific/Funafuti"),
+ ("Pacific/Galapagos", "Pacific/Galapagos"),
+ ("Pacific/Gambier", "Pacific/Gambier"),
+ ("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
+ ("Pacific/Guam", "Pacific/Guam"),
+ ("Pacific/Honolulu", "Pacific/Honolulu"),
+ ("Pacific/Johnston", "Pacific/Johnston"),
+ ("Pacific/Kanton", "Pacific/Kanton"),
+ ("Pacific/Kiritimati", "Pacific/Kiritimati"),
+ ("Pacific/Kosrae", "Pacific/Kosrae"),
+ ("Pacific/Kwajalein", "Pacific/Kwajalein"),
+ ("Pacific/Majuro", "Pacific/Majuro"),
+ ("Pacific/Marquesas", "Pacific/Marquesas"),
+ ("Pacific/Midway", "Pacific/Midway"),
+ ("Pacific/Nauru", "Pacific/Nauru"),
+ ("Pacific/Niue", "Pacific/Niue"),
+ ("Pacific/Norfolk", "Pacific/Norfolk"),
+ ("Pacific/Noumea", "Pacific/Noumea"),
+ ("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
+ ("Pacific/Palau", "Pacific/Palau"),
+ ("Pacific/Pitcairn", "Pacific/Pitcairn"),
+ ("Pacific/Pohnpei", "Pacific/Pohnpei"),
+ ("Pacific/Ponape", "Pacific/Ponape"),
+ ("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
+ ("Pacific/Rarotonga", "Pacific/Rarotonga"),
+ ("Pacific/Saipan", "Pacific/Saipan"),
+ ("Pacific/Samoa", "Pacific/Samoa"),
+ ("Pacific/Tahiti", "Pacific/Tahiti"),
+ ("Pacific/Tarawa", "Pacific/Tarawa"),
+ ("Pacific/Tongatapu", "Pacific/Tongatapu"),
+ ("Pacific/Truk", "Pacific/Truk"),
+ ("Pacific/Wake", "Pacific/Wake"),
+ ("Pacific/Wallis", "Pacific/Wallis"),
+ ("Pacific/Yap", "Pacific/Yap"),
+ ("Poland", "Poland"),
+ ("Portugal", "Portugal"),
+ ("ROC", "ROC"),
+ ("ROK", "ROK"),
+ ("Singapore", "Singapore"),
+ ("Turkey", "Turkey"),
+ ("UCT", "UCT"),
+ ("US/Alaska", "US/Alaska"),
+ ("US/Aleutian", "US/Aleutian"),
+ ("US/Arizona", "US/Arizona"),
+ ("US/Central", "US/Central"),
+ ("US/East-Indiana", "US/East-Indiana"),
+ ("US/Eastern", "US/Eastern"),
+ ("US/Hawaii", "US/Hawaii"),
+ ("US/Indiana-Starke", "US/Indiana-Starke"),
+ ("US/Michigan", "US/Michigan"),
+ ("US/Mountain", "US/Mountain"),
+ ("US/Pacific", "US/Pacific"),
+ ("US/Samoa", "US/Samoa"),
+ ("UTC", "UTC"),
+ ("Universal", "Universal"),
+ ("W-SU", "W-SU"),
+ ("WET", "WET"),
+ ("Zulu", "Zulu"),
+ ("localtime", "localtime"),
+ ],
+ default="UTC",
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0200_auto_20240327_1914.py b/bookwyrm/migrations/0200_auto_20240327_1914.py
new file mode 100644
index 000000000..38180b3f9
--- /dev/null
+++ b/bookwyrm/migrations/0200_auto_20240327_1914.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.25 on 2024-03-27 19:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0199_merge_20240326_1217"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="addfiletotar",
+ name="childjob_ptr",
+ ),
+ migrations.RemoveField(
+ model_name="addfiletotar",
+ name="parent_export_job",
+ ),
+ migrations.DeleteModel(
+ name="AddBookToUserExportJob",
+ ),
+ migrations.DeleteModel(
+ name="AddFileToTar",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0200_status_bookwyrm_st_thread__cf064f_idx.py b/bookwyrm/migrations/0200_status_bookwyrm_st_thread__cf064f_idx.py
new file mode 100644
index 000000000..daca654c7
--- /dev/null
+++ b/bookwyrm/migrations/0200_status_bookwyrm_st_thread__cf064f_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:05
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0199_status_bookwyrm_st_remote__06aeba_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="status",
+ index=models.Index(
+ fields=["thread_id"], name="bookwyrm_st_thread__cf064f_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0201_alter_hashtag_name_alter_user_localname.py b/bookwyrm/migrations/0201_alter_hashtag_name_alter_user_localname.py
new file mode 100644
index 000000000..4fe41ec35
--- /dev/null
+++ b/bookwyrm/migrations/0201_alter_hashtag_name_alter_user_localname.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.11 on 2024-04-01 21:09
+
+import bookwyrm.models.fields
+from django.db import migrations, models
+from django.contrib.postgres.operations import CreateCollation
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0200_alter_user_preferred_timezone"),
+ ]
+
+ operations = [
+ CreateCollation(
+ "case_insensitive",
+ provider="icu",
+ locale="und-u-ks-level2",
+ deterministic=False,
+ ),
+ migrations.AlterField(
+ model_name="hashtag",
+ name="name",
+ field=bookwyrm.models.fields.CharField(
+ db_collation="case_insensitive", max_length=256
+ ),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="localname",
+ field=models.CharField(
+ db_collation="case_insensitive",
+ max_length=255,
+ null=True,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_localname],
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0201_keypair_bookwyrm_ke_remote__472927_idx.py b/bookwyrm/migrations/0201_keypair_bookwyrm_ke_remote__472927_idx.py
new file mode 100644
index 000000000..e3d27a11b
--- /dev/null
+++ b/bookwyrm/migrations/0201_keypair_bookwyrm_ke_remote__472927_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0200_status_bookwyrm_st_thread__cf064f_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="keypair",
+ index=models.Index(
+ fields=["remote_id"], name="bookwyrm_ke_remote__472927_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0202_user_bookwyrm_us_usernam_b2546d_idx.py b/bookwyrm/migrations/0202_user_bookwyrm_us_usernam_b2546d_idx.py
new file mode 100644
index 000000000..d8666fe3f
--- /dev/null
+++ b/bookwyrm/migrations/0202_user_bookwyrm_us_usernam_b2546d_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:14
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0201_keypair_bookwyrm_ke_remote__472927_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="user",
+ index=models.Index(
+ fields=["username"], name="bookwyrm_us_usernam_b2546d_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0203_user_bookwyrm_us_is_acti_972dc4_idx.py b/bookwyrm/migrations/0203_user_bookwyrm_us_is_acti_972dc4_idx.py
new file mode 100644
index 000000000..b07f1c8a9
--- /dev/null
+++ b/bookwyrm/migrations/0203_user_bookwyrm_us_is_acti_972dc4_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.2.25 on 2024-04-03 19:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0202_user_bookwyrm_us_usernam_b2546d_idx"),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name="user",
+ index=models.Index(
+ fields=["is_active", "local"], name="bookwyrm_us_is_acti_972dc4_idx"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0204_merge_20240409_1042.py b/bookwyrm/migrations/0204_merge_20240409_1042.py
new file mode 100644
index 000000000..5656ac586
--- /dev/null
+++ b/bookwyrm/migrations/0204_merge_20240409_1042.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-04-09 10:42
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0197_mergedauthor_mergedbook"),
+ ("bookwyrm", "0203_user_bookwyrm_us_is_acti_972dc4_idx"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0205_merge_20240410_2022.py b/bookwyrm/migrations/0205_merge_20240410_2022.py
new file mode 100644
index 000000000..294f48487
--- /dev/null
+++ b/bookwyrm/migrations/0205_merge_20240410_2022.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-04-10 20:22
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0201_alter_hashtag_name_alter_user_localname"),
+ ("bookwyrm", "0204_merge_20240409_1042"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0205_merge_20240413_0232.py b/bookwyrm/migrations/0205_merge_20240413_0232.py
new file mode 100644
index 000000000..9cca29c45
--- /dev/null
+++ b/bookwyrm/migrations/0205_merge_20240413_0232.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.25 on 2024-04-13 02:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0200_auto_20240327_1914"),
+ ("bookwyrm", "0204_merge_20240409_1042"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0206_merge_20240415_1537.py b/bookwyrm/migrations/0206_merge_20240415_1537.py
new file mode 100644
index 000000000..454e69880
--- /dev/null
+++ b/bookwyrm/migrations/0206_merge_20240415_1537.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-04-15 15:37
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0205_merge_20240410_2022"),
+ ("bookwyrm", "0205_merge_20240413_0232"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0207_merge_20240629_0626.py b/bookwyrm/migrations/0207_merge_20240629_0626.py
new file mode 100644
index 000000000..b5a1a4556
--- /dev/null
+++ b/bookwyrm/migrations/0207_merge_20240629_0626.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-06-29 06:26
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0189_importjob_create_shelves"),
+ ("bookwyrm", "0206_merge_20240415_1537"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0207_sqlparse_update.py b/bookwyrm/migrations/0207_sqlparse_update.py
new file mode 100644
index 000000000..95c46eba2
--- /dev/null
+++ b/bookwyrm/migrations/0207_sqlparse_update.py
@@ -0,0 +1,51 @@
+# Generated by Django 4.2.11 on 2024-07-27 18:18
+
+from django.db import migrations, models
+import pgtrigger.compiler
+import pgtrigger.migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0206_merge_20240415_1537"),
+ ]
+
+ operations = [
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="author",
+ name="reset_book_search_vector_on_author_edit",
+ ),
+ pgtrigger.migrations.RemoveTrigger(
+ model_name="book",
+ name="update_search_vector_on_book_edit",
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="author",
+ trigger=pgtrigger.compiler.Trigger(
+ name="reset_book_search_vector_on_author_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="WITH updated_books AS (SELECT book_id FROM bookwyrm_book_authors WHERE author_id = new.id) UPDATE bookwyrm_book SET search_vector = '' FROM updated_books WHERE id = updated_books.book_id;RETURN NEW;",
+ hash="4eeb17d1c9c53f543615bcae1234bd0260adefcc",
+ operation='UPDATE OF "name", "aliases"',
+ pgid="pgtrigger_reset_book_search_vector_on_author_edit_a50c7",
+ table="bookwyrm_author",
+ when="AFTER",
+ ),
+ ),
+ ),
+ pgtrigger.migrations.AddTrigger(
+ model_name="book",
+ trigger=pgtrigger.compiler.Trigger(
+ name="update_search_vector_on_book_edit",
+ sql=pgtrigger.compiler.UpsertTriggerSql(
+ func="WITH author_names AS (SELECT array_to_string(bookwyrm_author.name || bookwyrm_author.aliases, ' ') AS name_and_aliases FROM bookwyrm_author LEFT JOIN bookwyrm_book_authors ON bookwyrm_author.id = bookwyrm_book_authors.author_id WHERE bookwyrm_book_authors.book_id = new.id) SELECT setweight(coalesce(nullif(to_tsvector('english', new.title), ''), to_tsvector('simple', new.title)), 'A') || setweight(to_tsvector('english', coalesce(new.subtitle, '')), 'B') || (SELECT setweight(to_tsvector('simple', coalesce(array_to_string(array_agg(name_and_aliases), ' '), '')), 'C') FROM author_names) || setweight(to_tsvector('english', coalesce(new.series, '')), 'D') INTO new.search_vector;RETURN NEW;",
+ hash="676d929ce95beff671544b6add09cf9360b6f299",
+ operation='INSERT OR UPDATE OF "title", "subtitle", "series", "search_vector"',
+ pgid="pgtrigger_update_search_vector_on_book_edit_bec58",
+ table="bookwyrm_book",
+ when="BEFORE",
+ ),
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0208_merge_0207_merge_20240629_0626_0207_sqlparse_update.py b/bookwyrm/migrations/0208_merge_0207_merge_20240629_0626_0207_sqlparse_update.py
new file mode 100644
index 000000000..24ef28e04
--- /dev/null
+++ b/bookwyrm/migrations/0208_merge_0207_merge_20240629_0626_0207_sqlparse_update.py
@@ -0,0 +1,13 @@
+# Generated by Django 4.2.11 on 2024-07-28 11:07
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0207_merge_20240629_0626"),
+ ("bookwyrm", "0207_sqlparse_update"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py
index f5b72f3e4..6bb99c7f2 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -20,19 +20,23 @@ from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .user import User, KeyPair
from .annual_goal import AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks
-from .report import Report, ReportComment
+from .report import Report, ReportAction
from .federated_server import FederatedServer
from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
+from .bookwyrm_import_job import BookwyrmImportJob
+from .bookwyrm_export_job import BookwyrmExportJob
+
+from .move import MoveUser
from .site import SiteSettings, Theme, SiteInvite
from .site import PasswordReset, InviteRequest
from .announcement import Announcement
from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
-from .notification import Notification
+from .notification import Notification, NotificationType
from .hashtag import Hashtag
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index 4b53c6e87..54ad11511 100644
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -6,8 +6,9 @@ from functools import reduce
import json
import operator
import logging
-from typing import List
+from typing import Any, Optional
from uuid import uuid4
+from typing_extensions import Self
import aiohttp
from Crypto.PublicKey import RSA
@@ -30,7 +31,7 @@ logger = logging.getLogger(__name__)
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
-# pylint: disable=invalid-name
+
def set_activity_from_property_field(activity, obj, field):
"""assign a model property value to the activity json"""
activity[field[1]] = getattr(obj, field[0])
@@ -85,7 +86,7 @@ class ActivitypubMixin:
super().__init__(*args, **kwargs)
@classmethod
- def find_existing_by_remote_id(cls, remote_id):
+ def find_existing_by_remote_id(cls, remote_id: str) -> Self:
"""look up a remote id in the db"""
return cls.find_existing({"id": remote_id})
@@ -137,7 +138,7 @@ class ActivitypubMixin:
queue=queue,
)
- def get_recipients(self, software=None) -> List[str]:
+ def get_recipients(self, software=None) -> list[str]:
"""figure out which inbox urls to post to"""
# first we have to figure out who should receive this activity
privacy = self.privacy if hasattr(self, "privacy") else "public"
@@ -151,8 +152,9 @@ class ActivitypubMixin:
# find anyone who's tagged in a status, for example
mentions = self.recipients if hasattr(self, "recipients") else []
- # we always send activities to explicitly mentioned users' inboxes
- recipients = [u.inbox for u in mentions or [] if not u.local]
+ # we always send activities to explicitly mentioned users (using shared inboxes
+ # where available to avoid duplicate submissions to a given instance)
+ recipients = {u.shared_inbox or u.inbox for u in mentions if not u.local}
# unless it's a dm, all the followers should receive the activity
if privacy != "direct":
@@ -167,23 +169,23 @@ class ActivitypubMixin:
# filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers
if software:
- queryset = queryset.filter(bookwyrm_user=(software == "bookwyrm"))
+ queryset = queryset.filter(bookwyrm_user=software == "bookwyrm")
# if there's a user, we only want to send to the user's followers
if user:
queryset = queryset.filter(following=user)
- # ideally, we will send to shared inboxes for efficiency
- shared_inboxes = (
- queryset.filter(shared_inbox__isnull=False)
- .values_list("shared_inbox", flat=True)
- .distinct()
+ # as above, we prefer shared inboxes if available
+ recipients.update(
+ queryset.filter(shared_inbox__isnull=False).values_list(
+ "shared_inbox", flat=True
+ )
)
- # but not everyone has a shared inbox
- inboxes = queryset.filter(shared_inbox__isnull=True).values_list(
- "inbox", flat=True
+ recipients.update(
+ queryset.filter(shared_inbox__isnull=True).values_list(
+ "inbox", flat=True
+ )
)
- recipients += list(shared_inboxes) + list(inboxes)
- return list(set(recipients))
+ return list(recipients)
def to_activity_dataclass(self):
"""convert from a model to an activity"""
@@ -198,13 +200,16 @@ class ActivitypubMixin:
class ObjectMixin(ActivitypubMixin):
"""add this mixin for object models that are AP serializable"""
- def save(self, *args, created=None, software=None, priority=BROADCAST, **kwargs):
+ def save(
+ self,
+ *args: Any,
+ created: Optional[bool] = None,
+ software: Any = None,
+ priority: str = BROADCAST,
+ broadcast: bool = True,
+ **kwargs: Any,
+ ) -> None:
"""broadcast created/updated/deleted objects as appropriate"""
- broadcast = kwargs.get("broadcast", True)
- # this bonus kwarg would cause an error in the base save method
- if "broadcast" in kwargs:
- del kwargs["broadcast"]
-
created = created or not bool(self.id)
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
@@ -507,14 +512,14 @@ def unfurl_related_field(related_field, sort_field=None):
@app.task(queue=BROADCAST)
-def broadcast_task(sender_id: int, activity: str, recipients: List[str]):
+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)
sender = user_model.objects.select_related("key_pair").get(id=sender_id)
asyncio.run(async_broadcast(recipients, sender, activity))
-async def async_broadcast(recipients: List[str], sender, data: str):
+async def async_broadcast(recipients: list[str], sender, data: str):
"""Send all the broadcasts simultaneously"""
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as session:
@@ -594,7 +599,7 @@ def to_ordered_collection_page(
if activity_page.has_next():
next_page = f"{remote_id}?page={activity_page.next_page_number()}"
if activity_page.has_previous():
- prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}"
+ prev_page = f"{remote_id}?page={activity_page.previous_page_number()}"
return activitypub.OrderedCollectionPage(
id=f"{remote_id}?page={page}",
partOf=remote_id,
diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py
index 94d978ec4..1067cbf1d 100644
--- a/bookwyrm/models/antispam.py
+++ b/bookwyrm/models/antispam.py
@@ -10,6 +10,7 @@ from django.utils.translation import gettext_lazy as _
from bookwyrm.tasks import app, MISC
from .base_model import BookWyrmModel
+from .notification import NotificationType
from .user import User
@@ -80,7 +81,7 @@ def automod_task():
with transaction.atomic():
for admin in admins:
notification, _ = notification_model.objects.get_or_create(
- user=admin, notification_type=notification_model.REPORT, read=False
+ user=admin, notification_type=NotificationType.REPORT, read=False
)
notification.related_reports.set(reports)
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index 5c0c087b2..20c4e9e00 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -1,18 +1,25 @@
""" database schema for info about authors """
+
import re
-from django.contrib.postgres.indexes import GinIndex
+from typing import Any
+
from django.db import models
+from django.contrib.postgres.indexes import GinIndex
+import pgtrigger
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
+from bookwyrm.utils.db import format_trigger
-from .book import BookDataModel
+from .book import BookDataModel, MergedAuthor
from . import fields
class Author(BookDataModel):
"""basic biographic info"""
+ merged_model = MergedAuthor
+
wikipedia_link = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
@@ -38,12 +45,12 @@ class Author(BookDataModel):
)
bio = fields.HtmlField(null=True, blank=True)
- def save(self, *args, **kwargs):
+ def save(self, *args: Any, **kwargs: Any) -> None:
"""normalize isni format"""
- if self.isni:
+ if self.isni is not None:
self.isni = re.sub(r"\s", "", self.isni)
- return super().save(*args, **kwargs)
+ super().save(*args, **kwargs)
@property
def isni_link(self):
@@ -63,11 +70,48 @@ class Author(BookDataModel):
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
- return f"https://{DOMAIN}/author/{self.id}"
-
- activity_serializer = activitypub.Author
+ return f"{BASE_URL}/author/{self.id}"
class Meta:
- """sets up postgres GIN index field"""
+ """sets up indexes and triggers"""
+
+ # pylint: disable=line-too-long
indexes = (GinIndex(fields=["search_vector"]),)
+ triggers = [
+ pgtrigger.Trigger(
+ name="update_search_vector_on_author_edit",
+ when=pgtrigger.Before,
+ operation=pgtrigger.Insert
+ | pgtrigger.UpdateOf("name", "aliases", "search_vector"),
+ func=format_trigger(
+ """new.search_vector :=
+ -- author name, with priority A
+ setweight(to_tsvector('simple', new.name), 'A') ||
+ -- author aliases, with priority B
+ setweight(to_tsvector('simple', coalesce(array_to_string(new.aliases, ' '), '')), 'B');
+ RETURN new;
+ """
+ ),
+ ),
+ pgtrigger.Trigger(
+ name="reset_book_search_vector_on_author_edit",
+ when=pgtrigger.After,
+ operation=pgtrigger.UpdateOf("name", "aliases"),
+ func=format_trigger(
+ """WITH updated_books AS (
+ SELECT book_id
+ FROM bookwyrm_book_authors
+ WHERE author_id = new.id
+ )
+ UPDATE bookwyrm_book
+ SET search_vector = ''
+ FROM updated_books
+ WHERE id = updated_books.book_id;
+ RETURN new;
+ """
+ ),
+ ),
+ ]
+
+ activity_serializer = activitypub.Author
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index 2d39e2a6f..ca13d9553 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -10,7 +10,7 @@ from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from .fields import RemoteIdField
@@ -38,7 +38,7 @@ class BookWyrmModel(models.Model):
def get_remote_id(self):
"""generate the url that resolves to the local object, without a slug"""
- base_path = f"https://{DOMAIN}"
+ base_path = BASE_URL
if hasattr(self, "user"):
base_path = f"{base_path}{self.user.local_path}"
@@ -53,7 +53,7 @@ class BookWyrmModel(models.Model):
@property
def local_path(self):
"""how to link to this object in the local app, with a slug"""
- local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
+ local = self.get_remote_id().replace(BASE_URL, "")
name = None
if hasattr(self, "name_field"):
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index c25f8fee2..368276523 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -1,27 +1,33 @@
""" database schema for books and shelves """
+
from itertools import chain
import re
+from typing import Any, Dict, Optional, Iterable
+from typing_extensions import Self
from django.contrib.postgres.search import SearchVectorField
from django.contrib.postgres.indexes import GinIndex
from django.core.cache import cache
from django.db import models, transaction
-from django.db.models import Prefetch
+from django.db.models import Prefetch, ManyToManyField
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
from imagekit.models import ImageSpecField
+import pgtrigger
from bookwyrm import activitypub
+from bookwyrm.isbn.isbn import hyphenator_singleton as hyphenator
from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import (
- DOMAIN,
+ BASE_URL,
DEFAULT_LANGUAGE,
LANGUAGE_ARTICLES,
ENABLE_PREVIEW_IMAGES,
ENABLE_THUMBNAIL_GENERATION,
)
+from bookwyrm.utils.db import format_trigger, add_update_fields
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
@@ -90,24 +96,133 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
abstract = True
- def save(self, *args, **kwargs):
+ def save(
+ self, *args: Any, update_fields: Optional[Iterable[str]] = None, **kwargs: Any
+ ) -> None:
"""ensure that the remote_id is within this instance"""
if self.id:
self.remote_id = self.get_remote_id()
+ update_fields = add_update_fields(update_fields, "remote_id")
else:
self.origin_id = self.remote_id
self.remote_id = None
- return super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "origin_id", "remote_id")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
# pylint: disable=arguments-differ
def broadcast(self, activity, sender, software="bookwyrm", **kwargs):
"""only send book data updates to other bookwyrm instances"""
super().broadcast(activity, sender, software=software, **kwargs)
+ def merge_into(self, canonical: Self, dry_run=False) -> Dict[str, Any]:
+ """merge this entity into another entity"""
+ if canonical.id == self.id:
+ raise ValueError(f"Cannot merge {self} into itself")
+
+ absorbed_fields = canonical.absorb_data_from(self, dry_run=dry_run)
+
+ if dry_run:
+ return absorbed_fields
+
+ canonical.save()
+
+ self.merged_model.objects.create(deleted_id=self.id, merged_into=canonical)
+
+ # move related models to canonical
+ related_models = [
+ (r.remote_field.name, r.related_model) for r in self._meta.related_objects
+ ]
+ for related_field, related_model in related_models:
+ # Skip the ManyToMany fields that aren’t 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 won’t lose the extra fields in the linking
+ # table.
+ # pylint: disable=protected-access
+ 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: self})
+ for related_obj in related_objs:
+ 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(self)
+
+ self.delete()
+ return absorbed_fields
+
+ def absorb_data_from(self, other: Self, dry_run=False) -> Dict[str, Any]:
+ """fill empty fields with values from another entity"""
+ absorbed_fields = {}
+ for data_field in self._meta.get_fields():
+ if not hasattr(data_field, "activitypub_field"):
+ continue
+ canonical_value = getattr(self, data_field.name)
+ other_value = getattr(other, data_field.name)
+ if not other_value:
+ continue
+ if isinstance(data_field, fields.ArrayField):
+ if new_values := list(set(other_value) - set(canonical_value)):
+ # append at the end (in no particular order)
+ if not dry_run:
+ setattr(self, data_field.name, canonical_value + new_values)
+ absorbed_fields[data_field.name] = new_values
+ elif isinstance(data_field, fields.PartialDateField):
+ if (
+ (not canonical_value)
+ or (other_value.has_day and not canonical_value.has_day)
+ or (other_value.has_month and not canonical_value.has_month)
+ ):
+ if not dry_run:
+ setattr(self, data_field.name, other_value)
+ absorbed_fields[data_field.name] = other_value
+ else:
+ if not canonical_value:
+ if not dry_run:
+ setattr(self, data_field.name, other_value)
+ absorbed_fields[data_field.name] = other_value
+ return absorbed_fields
+
+
+class MergedBookDataModel(models.Model):
+ """a BookDataModel instance that has been merged into another instance. kept
+ to be able to redirect old URLs"""
+
+ deleted_id = models.IntegerField(primary_key=True)
+
+ class Meta:
+ """abstract just like BookDataModel"""
+
+ abstract = True
+
+
+class MergedBook(MergedBookDataModel):
+ """an Book that has been merged into another one"""
+
+ merged_into = models.ForeignKey(
+ "Book", on_delete=models.PROTECT, related_name="absorbed"
+ )
+
+
+class MergedAuthor(MergedBookDataModel):
+ """an Author that has been merged into another one"""
+
+ merged_into = models.ForeignKey(
+ "Author", on_delete=models.PROTECT, related_name="absorbed"
+ )
+
class Book(BookDataModel):
"""a generic book, which can mean either an edition or a work"""
+ merged_model = MergedBook
+
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
# book/work metadata
@@ -133,8 +248,8 @@ class Book(BookDataModel):
preview_image = models.ImageField(
upload_to="previews/covers/", blank=True, null=True
)
- first_published_date = fields.DateTimeField(blank=True, null=True)
- published_date = fields.DateTimeField(blank=True, null=True)
+ first_published_date = fields.PartialDateField(blank=True, null=True)
+ published_date = fields.PartialDateField(blank=True, null=True)
objects = InheritanceManager()
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
@@ -188,9 +303,13 @@ class Book(BookDataModel):
"""properties of this edition, as a string"""
items = [
self.physical_format if hasattr(self, "physical_format") else None,
- f"{self.languages[0]} language"
- if self.languages and self.languages[0] and self.languages[0] != "English"
- else None,
+ (
+ f"{self.languages[0]} language"
+ if self.languages
+ and self.languages[0]
+ and self.languages[0] != "English"
+ else None
+ ),
str(self.published_date.year) if self.published_date else None,
", ".join(self.publishers) if hasattr(self, "publishers") else None,
]
@@ -199,21 +318,27 @@ class Book(BookDataModel):
@property
def alt_text(self):
"""image alt test"""
- text = self.title
- if self.edition_info:
- text += f" ({self.edition_info})"
- return text
+ author = f"{name}: " if (name := self.author_text) else ""
+ edition = f" ({info})" if (info := self.edition_info) else ""
+ return f"{author}{self.title}{edition}"
- def save(self, *args, **kwargs):
+ def save(self, *args: Any, **kwargs: Any) -> None:
"""can't be abstract for query reasons, but you shouldn't USE it"""
- if not isinstance(self, Edition) and not isinstance(self, Work):
+ if not isinstance(self, (Edition, Work)):
raise ValueError("Books should be added as Editions or Works")
- return super().save(*args, **kwargs)
+ super().save(*args, **kwargs)
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
- return f"https://{DOMAIN}/book/{self.id}"
+ return f"{BASE_URL}/book/{self.id}"
+
+ def guess_sort_title(self):
+ """Get a best-guess sort title for the current book"""
+ articles = chain(
+ *(LANGUAGE_ARTICLES.get(language, ()) for language in tuple(self.languages))
+ )
+ return re.sub(f'^{" |^".join(articles)} ', "", str(self.title).lower())
def __repr__(self):
# pylint: disable=consider-using-f-string
@@ -224,9 +349,49 @@ class Book(BookDataModel):
)
class Meta:
- """sets up postgres GIN index field"""
+ """set up indexes and triggers"""
+
+ # pylint: disable=line-too-long
indexes = (GinIndex(fields=["search_vector"]),)
+ triggers = [
+ pgtrigger.Trigger(
+ name="update_search_vector_on_book_edit",
+ when=pgtrigger.Before,
+ operation=pgtrigger.Insert
+ | pgtrigger.UpdateOf("title", "subtitle", "series", "search_vector"),
+ func=format_trigger(
+ """
+ WITH author_names AS (
+ SELECT array_to_string(bookwyrm_author.name || bookwyrm_author.aliases, ' ') AS name_and_aliases
+ FROM bookwyrm_author
+ LEFT JOIN bookwyrm_book_authors
+ ON bookwyrm_author.id = bookwyrm_book_authors.author_id
+ WHERE bookwyrm_book_authors.book_id = new.id
+ )
+ SELECT
+ -- title, with priority A (parse in English, default to simple if empty)
+ setweight(COALESCE(nullif(
+ to_tsvector('english', new.title), ''),
+ to_tsvector('simple', new.title)), 'A') ||
+
+ -- subtitle, with priority B (always in English?)
+ setweight(to_tsvector('english', COALESCE(new.subtitle, '')), 'B') ||
+
+ -- list of authors names and aliases (with priority C)
+ (SELECT setweight(to_tsvector('simple', COALESCE(array_to_string(ARRAY_AGG(name_and_aliases), ' '), '')), 'C')
+ FROM author_names
+ ) ||
+
+ --- last: series name, with lowest priority
+ setweight(to_tsvector('english', COALESCE(new.series, '')), 'D')
+
+ INTO new.search_vector;
+ RETURN new;
+ """
+ ),
+ )
+ ]
class Work(OrderedCollectionPageMixin, Book):
@@ -239,10 +404,11 @@ class Work(OrderedCollectionPageMixin, Book):
def save(self, *args, **kwargs):
"""set some fields on the edition object"""
+ super().save(*args, **kwargs)
+
# set rank
for edition in self.editions.all():
edition.save()
- return super().save(*args, **kwargs)
@property
def default_edition(self):
@@ -320,6 +486,11 @@ class Edition(Book):
serialize_reverse_fields = [("file_links", "fileLinks", "-created_date")]
deserialize_reverse_fields = [("file_links", "fileLinks")]
+ @property
+ def hyphenated_isbn13(self):
+ """generate the hyphenated version of the ISBN-13"""
+ return hyphenator.hyphenate(self.isbn_13)
+
def get_rank(self):
"""calculate how complete the data is on this edition"""
rank = 0
@@ -343,42 +514,61 @@ class Edition(Book):
# max rank is 9
return rank
- def save(self, *args, **kwargs):
+ def save(
+ self, *args: Any, update_fields: Optional[Iterable[str]] = None, **kwargs: Any
+ ) -> None:
"""set some fields on the edition object"""
# calculate isbn 10/13
- if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10:
+ if (
+ self.isbn_10 is None
+ and self.isbn_13 is not None
+ and self.isbn_13[:3] == "978"
+ ):
self.isbn_10 = isbn_13_to_10(self.isbn_13)
- if self.isbn_10 and not self.isbn_13:
+ update_fields = add_update_fields(update_fields, "isbn_10")
+ if self.isbn_13 is None and self.isbn_10 is not None:
self.isbn_13 = isbn_10_to_13(self.isbn_10)
+ update_fields = add_update_fields(update_fields, "isbn_13")
# normalize isbn format
- if self.isbn_10:
- self.isbn_10 = re.sub(r"[^0-9X]", "", self.isbn_10)
- if self.isbn_13:
- self.isbn_13 = re.sub(r"[^0-9X]", "", self.isbn_13)
+ if self.isbn_10 is not None:
+ self.isbn_10 = normalize_isbn(self.isbn_10)
+ if self.isbn_13 is not None:
+ self.isbn_13 = normalize_isbn(self.isbn_13)
# set rank
- self.edition_rank = self.get_rank()
-
- # clear author cache
- if self.id:
- for author_id in self.authors.values_list("id", flat=True):
- cache.delete(f"author-books-{author_id}")
+ if (new := self.get_rank()) != self.edition_rank:
+ self.edition_rank = new
+ update_fields = add_update_fields(update_fields, "edition_rank")
# 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()
- )
+ self.sort_title = self.guess_sort_title()
+ update_fields = add_update_fields(update_fields, "sort_title")
- return super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
+
+ # clear author cache
+ if self.id:
+ cache.delete_many(
+ [
+ f"author-books-{author_id}"
+ for author_id in self.authors.values_list("id", flat=True)
+ ]
+ )
+
+ @transaction.atomic
+ def repair(self):
+ """If an edition is in a bad state (missing a work), let's fix that"""
+ # made sure it actually NEEDS reapir
+ if self.parent_work:
+ return
+
+ new_work = Work.objects.create(title=self.title)
+ new_work.authors.set(self.authors.all())
+
+ self.parent_work = new_work
+ self.save(update_fields=["parent_work"], broadcast=False)
@classmethod
def viewer_aware_objects(cls, viewer):
@@ -446,6 +636,11 @@ def isbn_13_to_10(isbn_13):
return converted + str(checkdigit)
+def normalize_isbn(isbn):
+ """Remove unexpected characters from ISBN 10 or 13"""
+ return re.sub(r"[^0-9X]", "", isbn)
+
+
# pylint: disable=unused-argument
@receiver(models.signals.post_save, sender=Edition)
def preview_image(instance, *args, **kwargs):
diff --git a/bookwyrm/models/bookwyrm_export_job.py b/bookwyrm/models/bookwyrm_export_job.py
new file mode 100644
index 000000000..a42562f30
--- /dev/null
+++ b/bookwyrm/models/bookwyrm_export_job.py
@@ -0,0 +1,341 @@
+"""Export user account to tar.gz file for import into another Bookwyrm instance"""
+
+import logging
+import os
+
+from boto3.session import Session as BotoSession
+from s3_tar import S3Tar
+
+from django.db.models import BooleanField, FileField, JSONField
+from django.core.serializers.json import DjangoJSONEncoder
+from django.core.files.base import ContentFile
+from django.core.files.storage import storages
+
+from bookwyrm import settings
+
+from bookwyrm.models import AnnualGoal, ReadThrough, ShelfBook, ListItem
+from bookwyrm.models import Review, Comment, Quotation
+from bookwyrm.models import Edition
+from bookwyrm.models import UserFollows, User, UserBlocks
+from bookwyrm.models.job import ParentJob
+from bookwyrm.tasks import app, IMPORTS
+from bookwyrm.utils.tar import BookwyrmTarFile
+
+logger = logging.getLogger(__name__)
+
+
+class BookwyrmAwsSession(BotoSession):
+ """a boto session that always uses settings.AWS_S3_ENDPOINT_URL"""
+
+ def client(self, *args, **kwargs):
+ kwargs["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL
+ return super().client("s3", *args, **kwargs)
+
+
+def select_exports_storage():
+ """callable to allow for dependency on runtime configuration"""
+ return storages["exports"]
+
+
+class BookwyrmExportJob(ParentJob):
+ """entry for a specific request to export a bookwyrm user"""
+
+ export_data = FileField(null=True, storage=select_exports_storage)
+ export_json = JSONField(null=True, encoder=DjangoJSONEncoder)
+ json_completed = BooleanField(default=False)
+
+ def start_job(self):
+ """schedule the first task"""
+
+ task = create_export_json_task.delay(job_id=self.id)
+ self.task_id = task.id
+ self.save(update_fields=["task_id"])
+
+
+@app.task(queue=IMPORTS)
+def create_export_json_task(job_id):
+ """create the JSON data for the export"""
+
+ job = BookwyrmExportJob.objects.get(id=job_id)
+
+ # don't start the job if it was stopped from the UI
+ if job.complete:
+ return
+
+ try:
+ job.set_status("active")
+
+ # generate JSON structure
+ job.export_json = export_json(job.user)
+ job.save(update_fields=["export_json"])
+
+ # create archive in separate task
+ create_archive_task.delay(job_id=job.id)
+ except Exception as err: # pylint: disable=broad-except
+ logger.exception(
+ "create_export_json_task for %s failed with error: %s", job, err
+ )
+ job.set_status("failed")
+
+
+def archive_file_location(file, directory="") -> str:
+ """get the relative location of a file inside the archive"""
+ return os.path.join(directory, file.name)
+
+
+def add_file_to_s3_tar(s3_tar: S3Tar, storage, file, directory=""):
+ """
+ add file to S3Tar inside directory, keeping any directories under its
+ storage location
+ """
+ s3_tar.add_file(
+ os.path.join(storage.location, file.name),
+ folder=os.path.dirname(archive_file_location(file, directory=directory)),
+ )
+
+
+@app.task(queue=IMPORTS)
+def create_archive_task(job_id):
+ """create the archive containing the JSON file and additional files"""
+
+ job = BookwyrmExportJob.objects.get(id=job_id)
+
+ # don't start the job if it was stopped from the UI
+ if job.complete:
+ return
+
+ try:
+ export_task_id = str(job.task_id)
+ archive_filename = f"{export_task_id}.tar.gz"
+ export_json_bytes = DjangoJSONEncoder().encode(job.export_json).encode("utf-8")
+
+ user = job.user
+ editions = get_books_for_user(user)
+
+ if settings.USE_S3:
+ # Storage for writing temporary files
+ exports_storage = storages["exports"]
+
+ # Handle for creating the final archive
+ s3_tar = S3Tar(
+ exports_storage.bucket_name,
+ os.path.join(exports_storage.location, archive_filename),
+ session=BookwyrmAwsSession(),
+ )
+
+ # Save JSON file to a temporary location
+ export_json_tmp_file = os.path.join(export_task_id, "archive.json")
+ exports_storage.save(
+ export_json_tmp_file,
+ ContentFile(export_json_bytes),
+ )
+ s3_tar.add_file(
+ os.path.join(exports_storage.location, export_json_tmp_file)
+ )
+
+ # Add images to TAR
+ images_storage = storages["default"]
+
+ if user.avatar:
+ add_file_to_s3_tar(s3_tar, images_storage, user.avatar)
+
+ for edition in editions:
+ if edition.cover:
+ add_file_to_s3_tar(
+ s3_tar, images_storage, edition.cover, directory="images"
+ )
+
+ # Create archive and store file name
+ s3_tar.tar()
+ job.export_data = archive_filename
+ job.save(update_fields=["export_data"])
+
+ # Delete temporary files
+ exports_storage.delete(export_json_tmp_file)
+
+ else:
+ job.export_data = archive_filename
+ with job.export_data.open("wb") as tar_file:
+ with BookwyrmTarFile.open(mode="w:gz", fileobj=tar_file) as tar:
+ # save json file
+ tar.write_bytes(export_json_bytes)
+
+ # Add avatar image if present
+ if user.avatar:
+ tar.add_image(user.avatar)
+
+ for edition in editions:
+ if edition.cover:
+ tar.add_image(edition.cover, directory="images")
+ job.save(update_fields=["export_data"])
+
+ job.set_status("completed")
+
+ except Exception as err: # pylint: disable=broad-except
+ logger.exception("create_archive_task for %s failed with error: %s", job, err)
+ job.set_status("failed")
+
+
+def export_json(user: User):
+ """create export JSON"""
+ data = export_user(user) # in the root of the JSON structure
+ data["settings"] = export_settings(user)
+ data["goals"] = export_goals(user)
+ data["books"] = export_books(user)
+ data["saved_lists"] = export_saved_lists(user)
+ data["follows"] = export_follows(user)
+ data["blocks"] = export_blocks(user)
+ return data
+
+
+def export_user(user: User):
+ """export user data"""
+ data = user.to_activity()
+ if user.avatar:
+ data["icon"]["url"] = archive_file_location(user.avatar)
+ else:
+ data["icon"] = {}
+ return data
+
+
+def export_settings(user: User):
+ """Additional settings - can't be serialized as AP"""
+ vals = [
+ "show_goal",
+ "preferred_timezone",
+ "default_post_privacy",
+ "show_suggested_users",
+ ]
+ return {k: getattr(user, k) for k in vals}
+
+
+def export_saved_lists(user: User):
+ """add user saved lists to export JSON"""
+ return [l.remote_id for l in user.saved_lists.all()]
+
+
+def export_follows(user: User):
+ """add user follows to export JSON"""
+ follows = UserFollows.objects.filter(user_subject=user).distinct()
+ following = User.objects.filter(userfollows_user_object__in=follows).distinct()
+ return [f.remote_id for f in following]
+
+
+def export_blocks(user: User):
+ """add user blocks to export JSON"""
+ blocks = UserBlocks.objects.filter(user_subject=user).distinct()
+ blocking = User.objects.filter(userblocks_user_object__in=blocks).distinct()
+ return [b.remote_id for b in blocking]
+
+
+def export_goals(user: User):
+ """add user reading goals to export JSON"""
+ reading_goals = AnnualGoal.objects.filter(user=user).distinct()
+ return [
+ {"goal": goal.goal, "year": goal.year, "privacy": goal.privacy}
+ for goal in reading_goals
+ ]
+
+
+def export_books(user: User):
+ """add books to export JSON"""
+ editions = get_books_for_user(user)
+ return [export_book(user, edition) for edition in editions]
+
+
+def export_book(user: User, edition: Edition):
+ """add book to export JSON"""
+ data = {}
+ data["work"] = edition.parent_work.to_activity()
+ data["edition"] = edition.to_activity()
+
+ if edition.cover:
+ data["edition"]["cover"]["url"] = archive_file_location(
+ edition.cover, directory="images"
+ )
+
+ # authors
+ data["authors"] = [author.to_activity() for author in edition.authors.all()]
+
+ # Shelves this book is on
+ # Every ShelfItem is this book so we don't other serializing
+ shelf_books = (
+ ShelfBook.objects.select_related("shelf")
+ .filter(user=user, book=edition)
+ .distinct()
+ )
+ data["shelves"] = [shelfbook.shelf.to_activity() for shelfbook in shelf_books]
+
+ # Lists and ListItems
+ # ListItems include "notes" and "approved" so we need them
+ # even though we know it's this book
+ list_items = ListItem.objects.filter(book=edition, user=user).distinct()
+
+ data["lists"] = []
+ for item in list_items:
+ list_info = item.book_list.to_activity()
+ list_info[
+ "privacy"
+ ] = item.book_list.privacy # this isn't serialized so we add it
+ list_info["list_item"] = item.to_activity()
+ data["lists"].append(list_info)
+
+ # Statuses
+ # Can't use select_subclasses here because
+ # we need to filter on the "book" value,
+ # which is not available on an ordinary Status
+ for status in ["comments", "quotations", "reviews"]:
+ data[status] = []
+
+ comments = Comment.objects.filter(user=user, book=edition).all()
+ for status in comments:
+ obj = status.to_activity()
+ obj["progress"] = status.progress
+ obj["progress_mode"] = status.progress_mode
+ data["comments"].append(obj)
+
+ quotes = Quotation.objects.filter(user=user, book=edition).all()
+ for status in quotes:
+ obj = status.to_activity()
+ obj["position"] = status.position
+ obj["endposition"] = status.endposition
+ obj["position_mode"] = status.position_mode
+ data["quotations"].append(obj)
+
+ reviews = Review.objects.filter(user=user, book=edition).all()
+ data["reviews"] = [status.to_activity() for status in reviews]
+
+ # readthroughs can't be serialized to activity
+ book_readthroughs = (
+ ReadThrough.objects.filter(user=user, book=edition).distinct().values()
+ )
+ data["readthroughs"] = list(book_readthroughs)
+ return data
+
+
+def get_books_for_user(user):
+ """
+ Get all the books and editions related to a user.
+
+ We use union() instead of Q objects because it creates
+ multiple simple queries in stead of a much more complex DB query
+ that can time out.
+
+ """
+
+ shelf_eds = Edition.objects.select_related("parent_work").filter(shelves__user=user)
+ rt_eds = Edition.objects.select_related("parent_work").filter(
+ readthrough__user=user
+ )
+ review_eds = Edition.objects.select_related("parent_work").filter(review__user=user)
+ list_eds = Edition.objects.select_related("parent_work").filter(list__user=user)
+ comment_eds = Edition.objects.select_related("parent_work").filter(
+ comment__user=user
+ )
+ quote_eds = Edition.objects.select_related("parent_work").filter(
+ quotation__user=user
+ )
+
+ editions = shelf_eds.union(rt_eds, review_eds, list_eds, comment_eds, quote_eds)
+
+ return editions
diff --git a/bookwyrm/models/bookwyrm_import_job.py b/bookwyrm/models/bookwyrm_import_job.py
new file mode 100644
index 000000000..5229430eb
--- /dev/null
+++ b/bookwyrm/models/bookwyrm_import_job.py
@@ -0,0 +1,462 @@
+"""Import a user from another Bookwyrm instance"""
+
+import json
+import logging
+
+from django.db.models import FileField, JSONField, CharField
+from django.utils import timezone
+from django.utils.html import strip_tags
+from django.contrib.postgres.fields import ArrayField as DjangoArrayField
+
+from bookwyrm import activitypub
+from bookwyrm import models
+from bookwyrm.tasks import app, IMPORTS
+from bookwyrm.models.job import ParentJob, ParentTask, SubTask
+from bookwyrm.utils.tar import BookwyrmTarFile
+
+logger = logging.getLogger(__name__)
+
+
+class BookwyrmImportJob(ParentJob):
+ """entry for a specific request for importing a bookwyrm user backup"""
+
+ archive_file = FileField(null=True, blank=True)
+ import_data = JSONField(null=True)
+ required = DjangoArrayField(CharField(max_length=50, blank=True), blank=True)
+
+ def start_job(self):
+ """Start the job"""
+ start_import_task.delay(job_id=self.id, no_children=True)
+
+
+@app.task(queue=IMPORTS, base=ParentTask)
+def start_import_task(**kwargs):
+ """trigger the child import tasks for each user data"""
+ job = BookwyrmImportJob.objects.get(id=kwargs["job_id"])
+ archive_file = job.archive_file
+
+ # don't start the job if it was stopped from the UI
+ if job.complete:
+ return
+
+ try:
+ archive_file.open("rb")
+ with BookwyrmTarFile.open(mode="r:gz", fileobj=archive_file) as tar:
+ json_filename = next(
+ filter(lambda n: n.startswith("archive"), tar.getnames())
+ )
+ job.import_data = json.loads(tar.read(json_filename).decode("utf-8"))
+
+ if "include_user_profile" in job.required:
+ update_user_profile(job.user, tar, job.import_data)
+ if "include_user_settings" in job.required:
+ update_user_settings(job.user, job.import_data)
+ if "include_goals" in job.required:
+ update_goals(job.user, job.import_data.get("goals", []))
+ if "include_saved_lists" in job.required:
+ upsert_saved_lists(job.user, job.import_data.get("saved_lists", []))
+ if "include_follows" in job.required:
+ upsert_follows(job.user, job.import_data.get("follows", []))
+ if "include_blocks" in job.required:
+ upsert_user_blocks(job.user, job.import_data.get("blocks", []))
+
+ process_books(job, tar)
+
+ job.set_status("complete")
+ archive_file.close()
+
+ except Exception as err: # pylint: disable=broad-except
+ logger.exception("User Import Job %s Failed with error: %s", job.id, err)
+ job.set_status("failed")
+
+
+def process_books(job, tar):
+ """
+ Process user import data related to books
+ We always import the books even if not assigning
+ them to shelves, lists etc
+ """
+
+ books = job.import_data.get("books")
+
+ for data in books:
+ book = get_or_create_edition(data, tar)
+
+ if "include_shelves" in job.required:
+ upsert_shelves(book, job.user, data)
+
+ if "include_readthroughs" in job.required:
+ upsert_readthroughs(data.get("readthroughs"), job.user, book.id)
+
+ if "include_comments" in job.required:
+ upsert_statuses(
+ job.user, models.Comment, data.get("comments"), book.remote_id
+ )
+ if "include_quotations" in job.required:
+ upsert_statuses(
+ job.user, models.Quotation, data.get("quotations"), book.remote_id
+ )
+
+ if "include_reviews" in job.required:
+ upsert_statuses(
+ job.user, models.Review, data.get("reviews"), book.remote_id
+ )
+
+ if "include_lists" in job.required:
+ upsert_lists(job.user, data.get("lists"), book.id)
+
+
+def get_or_create_edition(book_data, tar):
+ """Take a JSON string of work and edition data,
+ find or create the edition and work in the database and
+ return an edition instance"""
+
+ edition = book_data.get("edition")
+ existing = models.Edition.find_existing(edition)
+ if existing:
+ return existing
+
+ # make sure we have the authors in the local DB
+ # replace the old author ids in the edition JSON
+ edition["authors"] = []
+ for author in book_data.get("authors"):
+ parsed_author = activitypub.parse(author)
+ instance = parsed_author.to_model(
+ model=models.Author, save=True, overwrite=True
+ )
+
+ edition["authors"].append(instance.remote_id)
+
+ # we will add the cover later from the tar
+ # don't try to load it from the old server
+ cover = edition.get("cover", {})
+ cover_path = cover.get("url", None)
+ edition["cover"] = {}
+
+ # first we need the parent work to exist
+ work = book_data.get("work")
+ work["editions"] = []
+ parsed_work = activitypub.parse(work)
+ work_instance = parsed_work.to_model(model=models.Work, save=True, overwrite=True)
+
+ # now we have a work we can add it to the edition
+ # and create the edition model instance
+ edition["work"] = work_instance.remote_id
+ parsed_edition = activitypub.parse(edition)
+ book = parsed_edition.to_model(model=models.Edition, save=True, overwrite=True)
+
+ # set the cover image from the tar
+ if cover_path:
+ tar.write_image_to_file(cover_path, book.cover)
+
+ return book
+
+
+def upsert_readthroughs(data, user, book_id):
+ """Take a JSON string of readthroughs and
+ find or create the instances in the database"""
+
+ for read_through in data:
+
+ obj = {}
+ keys = [
+ "progress_mode",
+ "start_date",
+ "finish_date",
+ "stopped_date",
+ "is_active",
+ ]
+ for key in keys:
+ obj[key] = read_through[key]
+ obj["user_id"] = user.id
+ obj["book_id"] = book_id
+
+ existing = models.ReadThrough.objects.filter(**obj).first()
+ if not existing:
+ models.ReadThrough.objects.create(**obj)
+
+
+def upsert_statuses(user, cls, data, book_remote_id):
+ """Take a JSON string of a status and
+ find or create the instances in the database"""
+
+ for status in data:
+ if is_alias(
+ user, status["attributedTo"]
+ ): # don't let l33t hax0rs steal other people's posts
+ # update ids and remove replies
+ status["attributedTo"] = user.remote_id
+ status["to"] = update_followers_address(user, status["to"])
+ status["cc"] = update_followers_address(user, status["cc"])
+ status[
+ "replies"
+ ] = (
+ {}
+ ) # this parses incorrectly but we can't set it without knowing the new id
+ status["inReplyToBook"] = book_remote_id
+ parsed = activitypub.parse(status)
+ if not status_already_exists(
+ user, parsed
+ ): # don't duplicate posts on multiple import
+
+ instance = parsed.to_model(model=cls, save=True, overwrite=True)
+
+ for val in [
+ "progress",
+ "progress_mode",
+ "position",
+ "endposition",
+ "position_mode",
+ ]:
+ if status.get(val):
+ instance.val = status[val]
+
+ instance.remote_id = instance.get_remote_id() # update the remote_id
+ instance.save() # save and broadcast
+
+ else:
+ logger.warning("User does not have permission to import statuses")
+
+
+def upsert_lists(user, lists, book_id):
+ """Take a list of objects each containing
+ a list and list item as AP objects
+
+ Because we are creating new IDs we can't assume the id
+ will exist or be accurate, so we only use to_model for
+ adding new items after checking whether they exist .
+
+ """
+
+ book = models.Edition.objects.get(id=book_id)
+
+ for blist in lists:
+ booklist = models.List.objects.filter(name=blist["name"], user=user).first()
+ if not booklist:
+
+ blist["owner"] = user.remote_id
+ parsed = activitypub.parse(blist)
+ booklist = parsed.to_model(model=models.List, save=True, overwrite=True)
+
+ booklist.privacy = blist["privacy"]
+ booklist.save()
+
+ item = models.ListItem.objects.filter(book=book, book_list=booklist).exists()
+ if not item:
+ count = booklist.books.count()
+ models.ListItem.objects.create(
+ book=book,
+ book_list=booklist,
+ user=user,
+ notes=blist["list_item"]["notes"],
+ approved=blist["list_item"]["approved"],
+ order=count + 1,
+ )
+
+
+def upsert_shelves(book, user, book_data):
+ """Take shelf JSON objects and create
+ DB entries if they don't already exist"""
+
+ shelves = book_data["shelves"]
+ for shelf in shelves:
+
+ book_shelf = models.Shelf.objects.filter(name=shelf["name"], user=user).first()
+
+ if not book_shelf:
+ book_shelf = models.Shelf.objects.create(name=shelf["name"], user=user)
+
+ # add the book as a ShelfBook if needed
+ if not models.ShelfBook.objects.filter(
+ book=book, shelf=book_shelf, user=user
+ ).exists():
+ models.ShelfBook.objects.create(
+ book=book, shelf=book_shelf, user=user, shelved_date=timezone.now()
+ )
+
+
+def update_user_profile(user, tar, data):
+ """update the user's profile from import data"""
+ name = data.get("name", None)
+ username = data.get("preferredUsername")
+ user.name = name if name else username
+ user.summary = strip_tags(data.get("summary", None))
+ user.save(update_fields=["name", "summary"])
+ if data["icon"].get("url"):
+ avatar_filename = next(filter(lambda n: n.startswith("avatar"), tar.getnames()))
+ tar.write_image_to_file(avatar_filename, user.avatar)
+
+
+def update_user_settings(user, data):
+ """update the user's settings from import data"""
+
+ update_fields = ["manually_approves_followers", "hide_follows", "discoverable"]
+
+ ap_fields = [
+ ("manuallyApprovesFollowers", "manually_approves_followers"),
+ ("hideFollows", "hide_follows"),
+ ("discoverable", "discoverable"),
+ ]
+
+ for (ap_field, bw_field) in ap_fields:
+ setattr(user, bw_field, data[ap_field])
+
+ bw_fields = [
+ "show_goal",
+ "show_suggested_users",
+ "default_post_privacy",
+ "preferred_timezone",
+ ]
+
+ for field in bw_fields:
+ update_fields.append(field)
+ setattr(user, field, data["settings"][field])
+
+ user.save(update_fields=update_fields)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def update_user_settings_task(job_id):
+ """wrapper task for user's settings import"""
+ parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+ return update_user_settings(parent_job.user, parent_job.import_data.get("user"))
+
+
+def update_goals(user, data):
+ """update the user's goals from import data"""
+
+ for goal in data:
+ # edit the existing goal if there is one
+ existing = models.AnnualGoal.objects.filter(
+ year=goal["year"], user=user
+ ).first()
+ if existing:
+ for k in goal.keys():
+ setattr(existing, k, goal[k])
+ existing.save()
+ else:
+ goal["user"] = user
+ models.AnnualGoal.objects.create(**goal)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def update_goals_task(job_id):
+ """wrapper task for user's goals import"""
+ parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+ return update_goals(parent_job.user, parent_job.import_data.get("goals"))
+
+
+def upsert_saved_lists(user, values):
+ """Take a list of remote ids and add as saved lists"""
+
+ for remote_id in values:
+ book_list = activitypub.resolve_remote_id(remote_id, models.List)
+ if book_list:
+ user.saved_lists.add(book_list)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def upsert_saved_lists_task(job_id):
+ """wrapper task for user's saved lists import"""
+ parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+ return upsert_saved_lists(
+ parent_job.user, parent_job.import_data.get("saved_lists")
+ )
+
+
+def upsert_follows(user, values):
+ """Take a list of remote ids and add as follows"""
+
+ for remote_id in values:
+ followee = activitypub.resolve_remote_id(remote_id, models.User)
+ if followee:
+ (follow_request, created,) = models.UserFollowRequest.objects.get_or_create(
+ user_subject=user,
+ user_object=followee,
+ )
+
+ if not created:
+ # this request probably failed to connect with the remote
+ # and should save to trigger a re-broadcast
+ follow_request.save()
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def upsert_follows_task(job_id):
+ """wrapper task for user's follows import"""
+ parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+ return upsert_follows(parent_job.user, parent_job.import_data.get("follows"))
+
+
+def upsert_user_blocks(user, user_ids):
+ """block users"""
+
+ for user_id in user_ids:
+ user_object = activitypub.resolve_remote_id(user_id, models.User)
+ if user_object:
+ exists = models.UserBlocks.objects.filter(
+ user_subject=user, user_object=user_object
+ ).exists()
+ if not exists:
+ models.UserBlocks.objects.create(
+ user_subject=user, user_object=user_object
+ )
+ # remove the blocked users's lists from the groups
+ models.List.remove_from_group(user, user_object)
+ # remove the blocked user from all blocker's owned groups
+ models.GroupMember.remove(user, user_object)
+
+
+@app.task(queue=IMPORTS, base=SubTask)
+def upsert_user_blocks_task(job_id):
+ """wrapper task for user's blocks import"""
+ parent_job = BookwyrmImportJob.objects.get(id=job_id)
+
+ return upsert_user_blocks(
+ parent_job.user, parent_job.import_data.get("blocked_users")
+ )
+
+
+def update_followers_address(user, field):
+ """statuses to or cc followers need to have the followers
+ address updated to the new local user"""
+
+ for i, audience in enumerate(field):
+ if audience.rsplit("/")[-1] == "followers":
+ field[i] = user.followers_url
+
+ return field
+
+
+def is_alias(user, remote_id):
+ """check that the user is listed as movedTo or also_known_as
+ in the remote user's profile"""
+
+ remote_user = activitypub.resolve_remote_id(
+ remote_id=remote_id, model=models.User, save=False
+ )
+
+ if remote_user:
+
+ if remote_user.moved_to:
+ return user.remote_id == remote_user.moved_to
+
+ if remote_user.also_known_as:
+ return user in remote_user.also_known_as.all()
+
+ return False
+
+
+def status_already_exists(user, status):
+ """check whether this status has already been published
+ by this user. We can't rely on to_model() because it
+ only matches on remote_id, which we have to change
+ *after* saving because it needs the primary key (id)"""
+
+ return models.Status.objects.filter(
+ user=user, content=status.content, published_date=status.published
+ ).exists()
diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py
index 99e73ab37..f4b5be04c 100644
--- a/bookwyrm/models/connector.py
+++ b/bookwyrm/models/connector.py
@@ -11,7 +11,7 @@ ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
class Connector(BookWyrmModel):
"""book data source connectors"""
- identifier = models.CharField(max_length=255, unique=True)
+ identifier = models.CharField(max_length=255, unique=True) # domain
priority = models.IntegerField(default=2)
name = models.CharField(max_length=255, null=True, blank=True)
connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)
diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py
index eb03d457e..5e08fc11d 100644
--- a/bookwyrm/models/federated_server.py
+++ b/bookwyrm/models/federated_server.py
@@ -16,7 +16,7 @@ FederationStatus = [
class FederatedServer(BookWyrmModel):
"""store which servers we federate with"""
- server_name = models.CharField(max_length=255, unique=True)
+ server_name = models.CharField(max_length=255, unique=True) # domain
status = models.CharField(
max_length=255, default="federated", choices=FederationStatus
)
@@ -61,8 +61,7 @@ class FederatedServer(BookWyrmModel):
).update(active=True, deactivation_reason=None)
@classmethod
- def is_blocked(cls, url):
+ def is_blocked(cls, url: str) -> bool:
"""look up if a domain is blocked"""
url = urlparse(url)
- domain = url.netloc
- return cls.objects.filter(server_name=domain, status="blocked").exists()
+ return cls.objects.filter(server_name=url.hostname, status="blocked").exists()
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index 27d8db1b5..92382e239 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -1,5 +1,6 @@
""" activitypub-aware django model fields """
from dataclasses import MISSING
+from datetime import datetime
import re
from uuid import uuid4
from urllib.parse import urljoin
@@ -19,6 +20,11 @@ from markdown import markdown
from bookwyrm import activitypub
from bookwyrm.connectors import get_image
from bookwyrm.utils.sanitizer import clean
+from bookwyrm.utils.partial_date import (
+ PartialDate,
+ PartialDateModel,
+ from_partial_isoformat,
+)
from bookwyrm.settings import MEDIA_FULL_URL
@@ -187,8 +193,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
def __init__(self, activitypub_field="preferredUsername", **kwargs):
self.activitypub_field = activitypub_field
- # I don't totally know why pylint is mad at this, but it makes it work
- super(ActivitypubFieldMixin, self).__init__( # pylint: disable=bad-super-call
+ super(ActivitypubFieldMixin, self).__init__(
_("username"),
max_length=150,
unique=True,
@@ -228,7 +233,6 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
def __init__(self, *args, **kwargs):
super().__init__(*args, max_length=255, choices=PrivacyLevels, default="public")
- # pylint: disable=invalid-name
def set_field_from_activity(
self, instance, data, overwrite=True, allow_external_connections=True
):
@@ -254,12 +258,12 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
if to == [self.public]:
setattr(instance, self.name, "public")
+ elif self.public in cc:
+ setattr(instance, self.name, "unlisted")
elif to == [user.followers_url]:
setattr(instance, self.name, "followers")
elif cc == []:
setattr(instance, self.name, "direct")
- elif self.public in cc:
- setattr(instance, self.name, "unlisted")
else:
setattr(instance, self.name, "followers")
return original == getattr(instance, self.name)
@@ -270,7 +274,6 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
if hasattr(instance, "mention_users"):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
- # pylint: disable=protected-access
followers = instance.user.followers_url
if instance.privacy == "public":
activity["to"] = [self.public]
@@ -438,7 +441,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
- # pylint: disable=arguments-differ,arguments-renamed,too-many-arguments
+ # pylint: disable=arguments-renamed,too-many-arguments
def set_field_from_activity(
self, instance, data, save=True, overwrite=True, allow_external_connections=True
):
@@ -476,16 +479,18 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if not url:
return None
- return activitypub.Document(url=url, name=alt)
+ return activitypub.Image(url=url, name=alt)
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
- if hasattr(image_slug, "url"):
- url = image_slug.url
- elif isinstance(image_slug, str):
+ if isinstance(image_slug, str):
url = image_slug
+ elif isinstance(image_slug, dict):
+ url = image_slug.get("url")
+ elif hasattr(image_slug, "url"): # Serialized to Image/Document object?
+ url = image_slug.url
else:
return None
@@ -534,8 +539,9 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
return value.isoformat()
def field_from_activity(self, value, allow_external_connections=True):
+ missing_fields = datetime(1970, 1, 1) # "2022-10" => "2022-10-01"
try:
- date_value = dateutil.parser.parse(value)
+ date_value = dateutil.parser.parse(value, default=missing_fields)
try:
return timezone.make_aware(date_value)
except ValueError:
@@ -544,6 +550,37 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
return None
+class PartialDateField(ActivitypubFieldMixin, PartialDateModel):
+ """activitypub-aware partial date field"""
+
+ def field_to_activity(self, value) -> str:
+ return value.partial_isoformat() if value else None
+
+ def field_from_activity(self, value, allow_external_connections=True):
+ # pylint: disable=no-else-return
+ try:
+ return from_partial_isoformat(value)
+ except ValueError:
+ pass
+
+ # fallback to full ISO-8601 parsing
+ try:
+ parsed = dateutil.parser.isoparse(value)
+ except (ValueError, ParserError):
+ return None
+
+ if timezone.is_aware(parsed):
+ return PartialDate.from_datetime(parsed)
+ else:
+ # Should not happen on the wire, but truncate down to date parts.
+ return PartialDate.from_date_parts(parsed.year, parsed.month, parsed.day)
+
+ # FIXME: decide whether to fix timestamps like "2023-09-30T21:00:00-03":
+ # clearly Oct 1st, not Sep 30th (an unwanted side-effect of USE_TZ). It's
+ # basically the remnants of #3028; there is a data migration pending (see …)
+ # but over the wire we might get these for an indeterminate amount of time.
+
+
class HtmlField(ActivitypubFieldMixin, models.TextField):
"""a text field for storing html"""
diff --git a/bookwyrm/models/group.py b/bookwyrm/models/group.py
index 003b23d02..40a32b5dc 100644
--- a/bookwyrm/models/group.py
+++ b/bookwyrm/models/group.py
@@ -1,8 +1,7 @@
""" do book related things with other users """
-from django.apps import apps
from django.db import models, IntegrityError, transaction
from django.db.models import Q
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from .base_model import BookWyrmModel
from . import fields
from .relationship import UserBlocks
@@ -18,7 +17,7 @@ class Group(BookWyrmModel):
def get_remote_id(self):
"""don't want the user to be in there in this case"""
- return f"https://{DOMAIN}/group/{self.id}"
+ return f"{BASE_URL}/group/{self.id}"
@classmethod
def followers_filter(cls, queryset, viewer):
@@ -143,26 +142,28 @@ class GroupMemberInvitation(models.Model):
@transaction.atomic
def accept(self):
"""turn this request into the real deal"""
+ # pylint: disable-next=import-outside-toplevel
+ from .notification import Notification, NotificationType # circular dependency
+
GroupMember.from_request(self)
- model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
- model.notify(
+ Notification.notify(
self.group.user,
self.user,
related_group=self.group,
- notification_type=model.ACCEPT,
+ notification_type=NotificationType.ACCEPT,
)
# let the other members know about it
for membership in self.group.memberships.all():
member = membership.user
if member not in (self.user, self.group.user):
- model.notify(
+ Notification.notify(
member,
self.user,
related_group=self.group,
- notification_type=model.JOIN,
+ notification_type=NotificationType.JOIN,
)
def reject(self):
diff --git a/bookwyrm/models/hashtag.py b/bookwyrm/models/hashtag.py
index 7894a3528..5126f012d 100644
--- a/bookwyrm/models/hashtag.py
+++ b/bookwyrm/models/hashtag.py
@@ -2,18 +2,19 @@
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
-from .fields import CICharField
+from .fields import CharField
class Hashtag(ActivitypubMixin, BookWyrmModel):
"a hashtag which can be used in statuses"
- name = CICharField(
+ name = CharField(
max_length=256,
blank=False,
null=False,
activitypub_field="name",
deduplication_field=True,
+ db_collation="case_insensitive",
)
name_field = "name"
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index bb5144297..5a6ba3f51 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -1,8 +1,10 @@
""" track progress of goodreads imports """
+from datetime import datetime
import math
import re
import dateutil.parser
+from django.core.exceptions import ObjectDoesNotExist
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
@@ -54,10 +56,11 @@ ImportStatuses = [
class ImportJob(models.Model):
"""entry for a specific request for book data import"""
- user = models.ForeignKey(User, on_delete=models.CASCADE)
+ user: User = models.ForeignKey(User, on_delete=models.CASCADE)
created_date = models.DateTimeField(default=timezone.now)
updated_date = models.DateTimeField(default=timezone.now)
- include_reviews = models.BooleanField(default=True)
+ include_reviews: bool = models.BooleanField(default=True)
+ create_shelves: bool = models.BooleanField(default=True)
mappings = models.JSONField()
source = models.CharField(max_length=100)
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
@@ -76,7 +79,7 @@ class ImportJob(models.Model):
self.save(update_fields=["task_id"])
- def complete_job(self):
+ def complete_job(self) -> None:
"""Report that the job has completed"""
self.status = "complete"
self.complete = True
@@ -244,11 +247,26 @@ class ImportItem(models.Model):
"""the goodreads shelf field"""
return self.normalized_data.get("shelf")
+ @property
+ def shelf_name(self):
+ """the goodreads shelf field"""
+ return self.normalized_data.get("shelf_name")
+
@property
def review(self):
"""a user-written review, to be imported with the book data"""
return self.normalized_data.get("review_body")
+ @property
+ def review_name(self):
+ """a user-written review name, to be imported with the book data"""
+ return self.normalized_data.get("review_name")
+
+ @property
+ def review_published(self):
+ """date the review was published - included in BookWyrm export csv"""
+ return self.normalized_data.get("review_published", None)
+
@property
def rating(self):
"""x/5 star rating for a book"""
@@ -259,38 +277,30 @@ class ImportItem(models.Model):
except ValueError:
return None
+ def _parse_datefield(self, field, /):
+ if not (date := self.normalized_data.get(field)):
+ return None
+
+ defaults = datetime(1970, 1, 1) # "2022-10" => "2022-10-01"
+ parsed = dateutil.parser.parse(date, default=defaults)
+
+ # Keep timezone if import already had one, else use default.
+ return parsed if timezone.is_aware(parsed) else timezone.make_aware(parsed)
+
@property
def date_added(self):
"""when the book was added to this dataset"""
- if self.normalized_data.get("date_added"):
- parsed_date_added = dateutil.parser.parse(
- self.normalized_data.get("date_added")
- )
-
- if timezone.is_aware(parsed_date_added):
- # Keep timezone if import already had one
- return parsed_date_added
-
- return timezone.make_aware(parsed_date_added)
- return None
+ return self._parse_datefield("date_added")
@property
def date_started(self):
"""when the book was started"""
- if self.normalized_data.get("date_started"):
- return timezone.make_aware(
- dateutil.parser.parse(self.normalized_data.get("date_started"))
- )
- return None
+ return self._parse_datefield("date_started")
@property
def date_read(self):
"""the date a book was completed"""
- if self.normalized_data.get("date_finished"):
- return timezone.make_aware(
- dateutil.parser.parse(self.normalized_data.get("date_finished"))
- )
- return None
+ return self._parse_datefield("date_finished")
@property
def reads(self):
@@ -359,7 +369,7 @@ def import_item_task(item_id):
try:
item.resolve()
- except Exception as err: # pylint: disable=broad-except
+ except Exception as err:
item.fail_reason = _("Error loading book")
item.save()
item.update_job()
@@ -375,7 +385,7 @@ def import_item_task(item_id):
item.update_job()
-def handle_imported_book(item):
+def handle_imported_book(item): # pylint: disable=too-many-branches
"""process a csv and then post about it"""
job = item.job
if job.complete:
@@ -392,13 +402,31 @@ def handle_imported_book(item):
item.book = item.book.edition
existing_shelf = ShelfBook.objects.filter(book=item.book, user=user).exists()
+ if job.create_shelves and item.shelf and not existing_shelf:
+ # shelve the book if it hasn't been shelved already
- # shelve the book if it hasn't been shelved already
- if item.shelf and not existing_shelf:
- desired_shelf = Shelf.objects.get(identifier=item.shelf, user=user)
shelved_date = item.date_added or timezone.now()
+ shelfname = getattr(item, "shelf_name", item.shelf)
+
+ try:
+ shelf = Shelf.objects.get(name=shelfname, user=user)
+ except ObjectDoesNotExist:
+ try:
+ shelf = Shelf.objects.get(identifier=item.shelf, user=user)
+ except ObjectDoesNotExist:
+
+ shelf = Shelf.objects.create(
+ user=user,
+ identifier=item.shelf,
+ name=shelfname,
+ privacy=job.privacy,
+ )
+
ShelfBook(
- book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
+ book=item.book,
+ shelf=shelf,
+ user=user,
+ shelved_date=shelved_date,
).save(priority=IMPORT_TRIGGERED)
for read in item.reads:
@@ -415,19 +443,25 @@ def handle_imported_book(item):
read.save()
if job.include_reviews and (item.rating or item.review) and not item.linked_review:
- # we don't know the publication date of the review,
- # but "now" is a bad guess
- published_date_guess = item.date_read or item.date_added
+ # we don't necessarily know the publication date of the review,
+ # but "now" is a bad guess unless we have no choice
+
+ published_date_guess = (
+ item.review_published or item.date_read or item.date_added or timezone.now()
+ )
if item.review:
+
# pylint: disable=consider-using-f-string
review_title = "Review of {!r} on {!r}".format(
item.book.title,
job.source,
)
+ review_name = getattr(item, "review_name", review_title)
+
review = Review.objects.filter(
user=user,
book=item.book,
- name=review_title,
+ name=review_name,
rating=item.rating,
published_date=published_date_guess,
).first()
@@ -435,7 +469,7 @@ def handle_imported_book(item):
review = Review(
user=user,
book=item.book,
- name=review_title,
+ name=review_name,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
diff --git a/bookwyrm/models/job.py b/bookwyrm/models/job.py
new file mode 100644
index 000000000..5a2653571
--- /dev/null
+++ b/bookwyrm/models/job.py
@@ -0,0 +1,307 @@
+"""Everything needed for Celery to multi-thread complex tasks."""
+
+from django.db import models
+from django.db import transaction
+from django.utils.translation import gettext_lazy as _
+from django.utils import timezone
+from bookwyrm.models.user import User
+
+from bookwyrm.tasks import app
+
+
+class Job(models.Model):
+ """Abstract model to store the state of a Task."""
+
+ class Status(models.TextChoices):
+ """Possible job states."""
+
+ PENDING = "pending", _("Pending")
+ ACTIVE = "active", _("Active")
+ COMPLETE = "complete", _("Complete")
+ STOPPED = "stopped", _("Stopped")
+ FAILED = "failed", _("Failed")
+
+ task_id = models.UUIDField(unique=True, null=True, blank=True)
+
+ created_date = models.DateTimeField(default=timezone.now)
+ updated_date = models.DateTimeField(default=timezone.now)
+ complete = models.BooleanField(default=False)
+ status = models.CharField(
+ max_length=50, choices=Status.choices, default=Status.PENDING, null=True
+ )
+
+ class Meta:
+ """Make it abstract"""
+
+ abstract = True
+
+ def complete_job(self):
+ """Report that the job has completed"""
+ if self.complete:
+ return
+
+ self.status = self.Status.COMPLETE
+ self.complete = True
+ self.updated_date = timezone.now()
+
+ self.save(update_fields=["status", "complete", "updated_date"])
+
+ def stop_job(self, reason=None):
+ """Stop the job"""
+ if self.complete:
+ return
+
+ self.__terminate_job()
+
+ if reason and reason == "failed":
+ self.status = self.Status.FAILED
+ else:
+ self.status = self.Status.STOPPED
+ self.complete = True
+ self.updated_date = timezone.now()
+
+ self.save(update_fields=["status", "complete", "updated_date"])
+
+ def set_status(self, status):
+ """Set job status"""
+ if self.complete:
+ return
+
+ if self.status == status:
+ return
+
+ if status == self.Status.COMPLETE:
+ self.complete_job()
+ return
+
+ if status == self.Status.STOPPED:
+ self.stop_job()
+ return
+
+ if status == self.Status.FAILED:
+ self.stop_job(reason="failed")
+ return
+
+ self.updated_date = timezone.now()
+ self.status = status
+
+ self.save(update_fields=["status", "updated_date"])
+
+ def __terminate_job(self):
+ """Tell workers to ignore and not execute this task."""
+ app.control.revoke(self.task_id, terminate=True)
+
+
+class ParentJob(Job):
+ """Store the state of a Task which can spawn many :model:`ChildJob`s to spread
+ resource load.
+
+ Intended to be sub-classed if necessary via proxy or
+ multi-table inheritance.
+ Extends :model:`Job`.
+ """
+
+ user = models.ForeignKey(User, on_delete=models.CASCADE)
+
+ def complete_job(self):
+ """Report that the job has completed and stop pending
+ children. Extend.
+ """
+ super().complete_job()
+ self.__terminate_pending_child_jobs()
+
+ def notify_child_job_complete(self):
+ """let the job know when the items get work done"""
+ if self.complete:
+ return
+
+ self.updated_date = timezone.now()
+ self.save(update_fields=["updated_date"])
+
+ if not self.complete and self.has_completed:
+ self.complete_job()
+
+ def __terminate_job(self): # pylint: disable=unused-private-member
+ """Tell workers to ignore and not execute this task
+ & pending child tasks. Extend.
+ """
+ super().__terminate_job()
+ self.__terminate_pending_child_jobs()
+
+ def __terminate_pending_child_jobs(self):
+ """Tell workers to ignore and not execute any pending child tasks."""
+ tasks = self.pending_child_jobs.filter(task_id__isnull=False).values_list(
+ "task_id", flat=True
+ )
+ app.control.revoke(list(tasks))
+
+ self.pending_child_jobs.update(status=self.Status.STOPPED)
+
+ @property
+ def has_completed(self):
+ """has this job finished"""
+ return not self.pending_child_jobs.exists()
+
+ @property
+ def pending_child_jobs(self):
+ """items that haven't been processed yet"""
+ return self.child_jobs.filter(complete=False)
+
+
+class ChildJob(Job):
+ """Stores the state of a Task for the related :model:`ParentJob`.
+
+ Intended to be sub-classed if necessary via proxy or
+ multi-table inheritance.
+ Extends :model:`Job`.
+ """
+
+ parent_job = models.ForeignKey(
+ ParentJob, on_delete=models.CASCADE, related_name="child_jobs"
+ )
+
+ def set_status(self, status):
+ """Set job and parent_job status. Extend."""
+ super().set_status(status)
+
+ if (
+ status == self.Status.ACTIVE
+ and self.parent_job.status == self.Status.PENDING
+ ):
+ self.parent_job.set_status(self.Status.ACTIVE)
+
+ def complete_job(self):
+ """Report to parent_job that the job has completed. Extend."""
+ super().complete_job()
+ self.parent_job.notify_child_job_complete()
+
+
+class ParentTask(app.Task):
+ """Used with ParentJob, Abstract Tasks execute code at specific points in
+ a Task's lifecycle, applying to all Tasks with the same 'base'.
+
+ All status & ParentJob.task_id assignment is managed here for you.
+ Usage e.g. @app.task(base=ParentTask)
+ """
+
+ def before_start(
+ self, task_id, args, kwargs
+ ): # pylint: disable=no-self-use, unused-argument
+ """Handler called before the task starts. Override.
+
+ Prepare ParentJob before the task starts.
+
+ Arguments:
+ task_id (str): Unique id of the task to execute.
+ args (Tuple): Original arguments for the task to execute.
+ kwargs (Dict): Original keyword arguments for the task to execute.
+
+ Keyword Arguments:
+ job_id (int): Unique 'id' of the ParentJob.
+ no_children (bool): If 'True' this is the only Task expected to run
+ for the given ParentJob.
+
+ Returns:
+ None: The return value of this handler is ignored.
+ """
+ job = ParentJob.objects.get(id=kwargs["job_id"])
+ job.task_id = task_id
+ job.save(update_fields=["task_id"])
+
+ if kwargs["no_children"]:
+ job.set_status(ChildJob.Status.ACTIVE)
+
+ def on_success(
+ self, retval, task_id, args, kwargs
+ ): # pylint: disable=no-self-use, unused-argument
+ """Run by the worker if the task executes successfully. Override.
+
+ Update ParentJob on Task complete.
+
+ Arguments:
+ retval (Any): The return value of the task.
+ task_id (str): Unique id of the executed task.
+ args (Tuple): Original arguments for the executed task.
+ kwargs (Dict): Original keyword arguments for the executed task.
+
+ Keyword Arguments:
+ job_id (int): Unique 'id' of the ParentJob.
+ no_children (bool): If 'True' this is the only Task expected to run
+ for the given ParentJob.
+
+ Returns:
+ None: The return value of this handler is ignored.
+ """
+
+ if kwargs["no_children"]:
+ job = ParentJob.objects.get(id=kwargs["job_id"])
+ job.complete_job()
+
+
+class SubTask(app.Task):
+ """Used with ChildJob, Abstract Tasks execute code at specific points in
+ a Task's lifecycle, applying to all Tasks with the same 'base'.
+
+ All status & ChildJob.task_id assignment is managed here for you.
+ Usage e.g. @app.task(base=SubTask)
+ """
+
+ def before_start(
+ self, task_id, *args, **kwargs
+ ): # pylint: disable=no-self-use, unused-argument
+ """Handler called before the task starts. Override.
+
+ Prepare ChildJob before the task starts.
+
+ Arguments:
+ task_id (str): Unique id of the task to execute.
+ args (Tuple): Original arguments for the task to execute.
+ kwargs (Dict): Original keyword arguments for the task to execute.
+
+ Keyword Arguments:
+ job_id (int): Unique 'id' of the ParentJob.
+ child_id (int): Unique 'id' of the ChildJob.
+
+ Returns:
+ None: The return value of this handler is ignored.
+ """
+ child_job = ChildJob.objects.get(id=kwargs["child_id"])
+ child_job.task_id = task_id
+ child_job.save(update_fields=["task_id"])
+ child_job.set_status(ChildJob.Status.ACTIVE)
+
+ def on_success(
+ self, retval, task_id, *args, **kwargs
+ ): # pylint: disable=no-self-use, unused-argument
+ """Run by the worker if the task executes successfully. Override.
+
+ Notify ChildJob of task completion.
+
+ Arguments:
+ retval (Any): The return value of the task.
+ task_id (str): Unique id of the executed task.
+ args (Tuple): Original arguments for the executed task.
+ kwargs (Dict): Original keyword arguments for the executed task.
+
+ Keyword Arguments:
+ job_id (int): Unique 'id' of the ParentJob.
+ child_id (int): Unique 'id' of the ChildJob.
+
+ Returns:
+ None: The return value of this handler is ignored.
+ """
+ subtask = ChildJob.objects.get(id=kwargs["child_id"])
+ subtask.complete_job()
+
+
+@transaction.atomic
+def create_child_job(parent_job, task_callback):
+ """Utility method for creating a ChildJob
+ and running a task to avoid DB race conditions
+ """
+ child_job = ChildJob.objects.create(parent_job=parent_job)
+ transaction.on_commit(
+ lambda: task_callback.delay(job_id=parent_job.id, child_id=child_job.id)
+ )
+
+ return child_job
diff --git a/bookwyrm/models/link.py b/bookwyrm/models/link.py
index d334a9d29..4519f0c81 100644
--- a/bookwyrm/models/link.py
+++ b/bookwyrm/models/link.py
@@ -1,4 +1,5 @@
""" outlink data """
+from typing import Optional, Iterable
from urllib.parse import urlparse
from django.core.exceptions import PermissionDenied
@@ -6,6 +7,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import ActivitypubMixin
from .base_model import BookWyrmModel
from . import fields
@@ -34,17 +36,19 @@ class Link(ActivitypubMixin, BookWyrmModel):
"""link name via the associated domain"""
return self.domain.name
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""create a link"""
# get or create the associated domain
if not self.domain:
- domain = urlparse(self.url).netloc
+ domain = urlparse(self.url).hostname
self.domain, _ = LinkDomain.objects.get_or_create(domain=domain)
+ update_fields = add_update_fields(update_fields, "domain")
# this is never broadcast, the owning model broadcasts an update
if "broadcast" in kwargs:
del kwargs["broadcast"]
- return super().save(*args, **kwargs)
+
+ super().save(*args, update_fields=update_fields, **kwargs)
AvailabilityChoices = [
@@ -88,8 +92,10 @@ class LinkDomain(BookWyrmModel):
return
raise PermissionDenied()
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""set a default name"""
if not self.name:
self.name = self.domain
- super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "name")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index 68ce6e862..76fc91c6e 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -1,4 +1,5 @@
""" make a list of books!! """
+from typing import Optional, Iterable
import uuid
from django.core.exceptions import PermissionDenied
@@ -8,7 +9,8 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
@@ -59,7 +61,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
def get_remote_id(self):
"""don't want the user to be in there in this case"""
- return f"https://{DOMAIN}/list/{self.id}"
+ return f"{BASE_URL}/list/{self.id}"
@property
def collection_queryset(self):
@@ -154,11 +156,13 @@ class List(OrderedCollectionMixin, BookWyrmModel):
group=None, curation="closed"
)
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""on save, update embed_key and avoid clash with existing code"""
if not self.embed_key:
self.embed_key = uuid.uuid4()
- super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "embed_key")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel):
diff --git a/bookwyrm/models/move.py b/bookwyrm/models/move.py
new file mode 100644
index 000000000..5038058b7
--- /dev/null
+++ b/bookwyrm/models/move.py
@@ -0,0 +1,68 @@
+""" move an object including migrating a user account """
+from django.core.exceptions import PermissionDenied
+from django.db import models
+
+from bookwyrm import activitypub
+from .activitypub_mixin import ActivityMixin
+from .base_model import BookWyrmModel
+from . import fields
+from .notification import Notification, NotificationType
+
+
+class Move(ActivityMixin, BookWyrmModel):
+ """migrating an activitypub object"""
+
+ user = fields.ForeignKey(
+ "User", on_delete=models.PROTECT, activitypub_field="actor"
+ )
+
+ object = fields.CharField(
+ max_length=255,
+ blank=False,
+ null=False,
+ activitypub_field="object",
+ )
+
+ origin = fields.CharField(
+ max_length=255,
+ blank=True,
+ null=True,
+ default="",
+ activitypub_field="origin",
+ )
+
+ activity_serializer = activitypub.Move
+
+
+class MoveUser(Move):
+ """migrating an activitypub user account"""
+
+ target = fields.ForeignKey(
+ "User",
+ on_delete=models.PROTECT,
+ related_name="move_target",
+ activitypub_field="target",
+ )
+
+ def save(self, *args, **kwargs):
+ """update user info and broadcast it"""
+
+ # only allow if the source is listed in the target's alsoKnownAs
+ if self.user not in self.target.also_known_as.all():
+ raise PermissionDenied()
+
+ self.user.also_known_as.add(self.target.id)
+ self.user.update_active_date()
+ self.user.moved_to = self.target.remote_id
+ self.user.save(update_fields=["moved_to"])
+
+ if self.user.local:
+ kwargs["broadcast"] = True # Only broadcast if we are initiating the Move
+
+ super().save(*args, **kwargs)
+
+ for follower in self.user.followers.all():
+ if follower.local:
+ Notification.notify(
+ follower, self.user, notification_type=NotificationType.MOVE
+ )
diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py
index 522038f9a..ca1e2aeb0 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -1,12 +1,21 @@
""" alert a user to activity """
from django.db import models, transaction
from django.dispatch import receiver
+from bookwyrm.models.bookwyrm_export_job import BookwyrmExportJob
from .base_model import BookWyrmModel
-from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
+from . import (
+ Boost,
+ Favorite,
+ GroupMemberInvitation,
+ ImportJob,
+ BookwyrmImportJob,
+ LinkDomain,
+)
from . import ListItem, Report, Status, User, UserFollowRequest
+from .site import InviteRequest
-class Notification(BookWyrmModel):
+class NotificationType(models.TextChoices):
"""you've been tagged, liked, followed, etc"""
# Status interactions
@@ -22,6 +31,8 @@ class Notification(BookWyrmModel):
# Imports
IMPORT = "IMPORT"
+ USER_IMPORT = "USER_IMPORT"
+ USER_EXPORT = "USER_EXPORT"
# List activity
ADD = "ADD"
@@ -29,6 +40,7 @@ class Notification(BookWyrmModel):
# Admin
REPORT = "REPORT"
LINK_DOMAIN = "LINK_DOMAIN"
+ INVITE_REQUEST = "INVITE_REQUEST"
# Groups
INVITE = "INVITE"
@@ -40,12 +52,12 @@ class Notification(BookWyrmModel):
GROUP_NAME = "GROUP_NAME"
GROUP_DESCRIPTION = "GROUP_DESCRIPTION"
- # pylint: disable=line-too-long
- 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} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
- )
+ # Migrations
+ MOVE = "MOVE"
+
+
+class Notification(BookWyrmModel):
+ """a notification object"""
user = models.ForeignKey("User", on_delete=models.CASCADE)
read = models.BooleanField(default=False)
@@ -61,11 +73,15 @@ class Notification(BookWyrmModel):
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
+ related_user_export = models.ForeignKey(
+ "BookwyrmExportJob", on_delete=models.CASCADE, null=True
+ )
related_list_items = models.ManyToManyField(
"ListItem", symmetrical=False, related_name="notifications"
)
- related_reports = models.ManyToManyField("Report", symmetrical=False)
- related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False)
+ related_reports = models.ManyToManyField("Report")
+ related_link_domains = models.ManyToManyField("LinkDomain")
+ related_invite_requests = models.ManyToManyField("InviteRequest")
@classmethod
@transaction.atomic
@@ -90,11 +106,11 @@ class Notification(BookWyrmModel):
user=user,
related_users=related_user,
related_list_items__book_list=list_item.book_list,
- notification_type=Notification.ADD,
+ notification_type=NotificationType.ADD,
).first()
if not notification:
notification = cls.objects.create(
- user=user, notification_type=Notification.ADD
+ user=user, notification_type=NotificationType.ADD
)
notification.related_users.add(related_user)
notification.related_list_items.add(list_item)
@@ -121,7 +137,7 @@ def notify_on_fav(sender, instance, *args, **kwargs):
instance.status.user,
instance.user,
related_status=instance.status,
- notification_type=Notification.FAVORITE,
+ notification_type=NotificationType.FAVORITE,
)
@@ -135,7 +151,7 @@ def notify_on_unfav(sender, instance, *args, **kwargs):
instance.status.user,
instance.user,
related_status=instance.status,
- notification_type=Notification.FAVORITE,
+ notification_type=NotificationType.FAVORITE,
)
@@ -160,7 +176,7 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
instance.reply_parent.user,
instance.user,
related_status=instance,
- notification_type=Notification.REPLY,
+ notification_type=NotificationType.REPLY,
)
for mention_user in instance.mention_users.all():
@@ -172,7 +188,7 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
Notification.notify(
mention_user,
instance.user,
- notification_type=Notification.MENTION,
+ notification_type=NotificationType.MENTION,
related_status=instance,
)
@@ -191,7 +207,7 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
instance.boosted_status.user,
instance.user,
related_status=instance.boosted_status,
- notification_type=Notification.BOOST,
+ notification_type=NotificationType.BOOST,
)
@@ -203,7 +219,7 @@ def notify_user_on_unboost(sender, instance, *args, **kwargs):
instance.boosted_status.user,
instance.user,
related_status=instance.boosted_status,
- notification_type=Notification.BOOST,
+ notification_type=NotificationType.BOOST,
)
@@ -218,11 +234,41 @@ def notify_user_on_import_complete(
return
Notification.objects.get_or_create(
user=instance.user,
- notification_type=Notification.IMPORT,
+ notification_type=NotificationType.IMPORT,
related_import=instance,
)
+@receiver(models.signals.post_save, sender=BookwyrmImportJob)
+# pylint: disable=unused-argument
+def notify_user_on_user_import_complete(
+ sender, instance, *args, update_fields=None, **kwargs
+):
+ """we imported your user details! aren't you proud of us"""
+ update_fields = update_fields or []
+ if not instance.complete or "complete" not in update_fields:
+ return
+ Notification.objects.create(
+ user=instance.user, notification_type=NotificationType.USER_IMPORT
+ )
+
+
+@receiver(models.signals.post_save, sender=BookwyrmExportJob)
+# pylint: disable=unused-argument
+def notify_user_on_user_export_complete(
+ sender, instance, *args, update_fields=None, **kwargs
+):
+ """we exported your user details! aren't you proud of us"""
+ update_fields = update_fields or []
+ if not instance.complete or "complete" not in update_fields:
+ return
+ Notification.objects.create(
+ user=instance.user,
+ notification_type=NotificationType.USER_EXPORT,
+ related_user_export=instance,
+ )
+
+
@receiver(models.signals.post_save, sender=Report)
@transaction.atomic
# pylint: disable=unused-argument
@@ -233,11 +279,10 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
return
# moderators and superusers should be notified
- admins = User.admins()
- for admin in admins:
+ for admin in User.admins():
notification, _ = Notification.objects.get_or_create(
user=admin,
- notification_type=Notification.REPORT,
+ notification_type=NotificationType.REPORT,
read=False,
)
notification.related_reports.add(instance)
@@ -253,16 +298,33 @@ def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
return
# moderators and superusers should be notified
- admins = User.admins()
- for admin in admins:
+ for admin in User.admins():
notification, _ = Notification.objects.get_or_create(
user=admin,
- notification_type=Notification.LINK_DOMAIN,
+ notification_type=NotificationType.LINK_DOMAIN,
read=False,
)
notification.related_link_domains.add(instance)
+@receiver(models.signals.post_save, sender=InviteRequest)
+@transaction.atomic
+# pylint: disable=unused-argument
+def notify_admins_on_invite_request(sender, instance, created, *args, **kwargs):
+ """need to handle a new invite request"""
+ if not created:
+ return
+
+ # moderators and superusers should be notified
+ for admin in User.admins():
+ notification, _ = Notification.objects.get_or_create(
+ user=admin,
+ notification_type=NotificationType.INVITE_REQUEST,
+ read=False,
+ )
+ notification.related_invite_requests.add(instance)
+
+
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
# pylint: disable=unused-argument
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
@@ -271,7 +333,7 @@ def notify_user_on_group_invite(sender, instance, *args, **kwargs):
instance.user,
instance.group.user,
related_group=instance.group,
- notification_type=Notification.INVITE,
+ notification_type=NotificationType.INVITE,
)
@@ -309,11 +371,12 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
notification = Notification.objects.filter(
user=instance.user_object,
related_users=instance.user_subject,
- notification_type=Notification.FOLLOW_REQUEST,
+ notification_type=NotificationType.FOLLOW_REQUEST,
).first()
if not notification:
notification = Notification.objects.create(
- user=instance.user_object, notification_type=Notification.FOLLOW_REQUEST
+ user=instance.user_object,
+ notification_type=NotificationType.FOLLOW_REQUEST,
)
notification.related_users.set([instance.user_subject])
notification.read = False
@@ -323,6 +386,6 @@ def notify_user_on_follow(sender, instance, created, *args, **kwargs):
Notification.notify(
instance.user_object,
instance.user_subject,
- notification_type=Notification.FOLLOW,
+ notification_type=NotificationType.FOLLOW,
read=False,
)
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
index 4911c715b..7700b4a87 100644
--- a/bookwyrm/models/readthrough.py
+++ b/bookwyrm/models/readthrough.py
@@ -1,9 +1,13 @@
""" progress in a book """
+from typing import Optional, Iterable
+
from django.core import validators
from django.core.cache import cache
from django.db import models
from django.db.models import F, Q
+from bookwyrm.utils.db import add_update_fields
+
from .base_model import BookWyrmModel
@@ -30,14 +34,17 @@ class ReadThrough(BookWyrmModel):
stopped_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True)
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""update user active time"""
- 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:
self.is_active = False
- super().save(*args, **kwargs)
+ update_fields = add_update_fields(update_fields, "is_active")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
+
+ cache.delete(f"latest_read_through-{self.user_id}-{self.book_id}")
+ self.user.update_active_date()
def create_update(self):
"""add update to the readthrough"""
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index 7af6ad5ab..ed630fbe5 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -38,14 +38,16 @@ class UserRelationship(BookWyrmModel):
def save(self, *args, **kwargs):
"""clear the template cache"""
- clear_cache(self.user_subject, self.user_object)
super().save(*args, **kwargs)
+ clear_cache(self.user_subject, self.user_object)
+
def delete(self, *args, **kwargs):
"""clear the template cache"""
- clear_cache(self.user_subject, self.user_object)
super().delete(*args, **kwargs)
+ clear_cache(self.user_subject, self.user_object)
+
class Meta:
"""relationships should be unique"""
@@ -65,6 +67,13 @@ class UserRelationship(BookWyrmModel):
base_path = self.user_subject.remote_id
return f"{base_path}#follows/{self.id}"
+ def get_accept_reject_id(self, status):
+ """get id for sending an accept or reject of a local user"""
+
+ base_path = self.user_object.remote_id
+ status_id = self.id or 0
+ return f"{base_path}#{status}/{status_id}"
+
class UserFollows(ActivityMixin, UserRelationship):
"""Following a user"""
@@ -105,6 +114,20 @@ class UserFollows(ActivityMixin, UserRelationship):
)
return obj
+ def reject(self):
+ """generate a Reject for this follow. This would normally happen
+ when a user deletes a follow they previously accepted"""
+
+ if self.user_object.local:
+ activity = activitypub.Reject(
+ id=self.get_accept_reject_id(status="rejects"),
+ actor=self.user_object.remote_id,
+ object=self.to_activity(),
+ ).serialize()
+ self.broadcast(activity, self.user_object)
+
+ self.delete()
+
class UserFollowRequest(ActivitypubMixin, UserRelationship):
"""following a user requires manual or automatic confirmation"""
@@ -112,7 +135,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
status = "follow_request"
activity_serializer = activitypub.Follow
- def save(self, *args, broadcast=True, **kwargs): # pylint: disable=arguments-differ
+ def save(self, *args, broadcast=True, **kwargs):
"""make sure the follow or block relationship doesn't already exist"""
# if there's a request for a follow that already exists, accept it
# without changing the local database state
@@ -148,13 +171,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
if not manually_approves:
self.accept()
- def get_accept_reject_id(self, status):
- """get id for sending an accept or reject of a local user"""
-
- base_path = self.user_object.remote_id
- status_id = self.id or 0
- return f"{base_path}#{status}/{status_id}"
-
def accept(self, broadcast_only=False):
"""turn this request into the real deal"""
user = self.user_object
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index f6e665053..64ade3a40 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -1,11 +1,27 @@
""" flagged for moderation """
from django.core.exceptions import PermissionDenied
from django.db import models
+from django.utils.translation import gettext_lazy as _
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from .base_model import BookWyrmModel
+# Report action enums
+COMMENT = "comment"
+RESOLVE = "resolve"
+REOPEN = "reopen"
+MESSAGE_REPORTER = "message_reporter"
+MESSAGE_OFFENDER = "message_offender"
+USER_SUSPENSION = "user_suspension"
+USER_UNSUSPENSION = "user_unsuspension"
+USER_DELETION = "user_deletion"
+USER_PERMS = "user_perms"
+BLOCK_DOMAIN = "block_domain"
+APPROVE_DOMAIN = "approve_domain"
+DELETE_ITEM = "delete_item"
+
+
class Report(BookWyrmModel):
"""reported status or user"""
@@ -30,7 +46,33 @@ class Report(BookWyrmModel):
raise PermissionDenied()
def get_remote_id(self):
- return f"https://{DOMAIN}/settings/reports/{self.id}"
+ return f"{BASE_URL}/settings/reports/{self.id}"
+
+ def comment(self, user, note):
+ """comment on a report"""
+ ReportAction.objects.create(
+ action_type=COMMENT, user=user, note=note, report=self
+ )
+
+ def resolve(self, user):
+ """Mark a report as complete"""
+ self.resolved = True
+ self.save()
+ ReportAction.objects.create(action_type=RESOLVE, user=user, report=self)
+
+ def reopen(self, user):
+ """Wait! This report isn't complete after all"""
+ self.resolved = False
+ self.save()
+ ReportAction.objects.create(action_type=REOPEN, user=user, report=self)
+
+ @classmethod
+ def record_action(cls, report_id: int, action: str, user):
+ """Note that someone did something"""
+ if not report_id:
+ return
+ report = cls.objects.get(id=report_id)
+ ReportAction.objects.create(action_type=action, user=user, report=report)
class Meta:
"""set order by default"""
@@ -38,14 +80,33 @@ class Report(BookWyrmModel):
ordering = ("-created_date",)
-class ReportComment(BookWyrmModel):
+ReportActionTypes = [
+ (COMMENT, _("Comment")),
+ (RESOLVE, _("Resolved report")),
+ (REOPEN, _("Re-opened report")),
+ (MESSAGE_REPORTER, _("Messaged reporter")),
+ (MESSAGE_OFFENDER, _("Messaged reported user")),
+ (USER_SUSPENSION, _("Suspended user")),
+ (USER_UNSUSPENSION, _("Un-suspended user")),
+ (USER_PERMS, _("Changed user permission level")),
+ (USER_DELETION, _("Deleted user account")),
+ (BLOCK_DOMAIN, _("Blocked domain")),
+ (APPROVE_DOMAIN, _("Approved domain")),
+ (DELETE_ITEM, _("Deleted item")),
+]
+
+
+class ReportAction(BookWyrmModel):
"""updates on a report"""
user = models.ForeignKey("User", on_delete=models.PROTECT)
+ action_type = models.CharField(
+ max_length=20, blank=False, default="comment", choices=ReportActionTypes
+ )
note = models.TextField()
report = models.ForeignKey(Report, on_delete=models.PROTECT)
class Meta:
"""sort comments"""
- ordering = ("-created_date",)
+ ordering = ("created_date",)
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index 3d92f8d43..0b9ef2b09 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -1,13 +1,15 @@
""" puttin' books on shelves """
import re
+from typing import Optional, Iterable
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import BASE_URL
from bookwyrm.tasks import BROADCAST
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@@ -44,8 +46,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
"""set the identifier"""
super().save(*args, priority=priority, **kwargs)
if not self.identifier:
+ # this needs the auto increment ID from the save() above
self.identifier = self.get_identifier()
- super().save(*args, **kwargs, broadcast=False)
+ super().save(*args, **kwargs, broadcast=False, update_fields={"identifier"})
def get_identifier(self):
"""custom-shelf-123 for the url"""
@@ -71,7 +74,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
@property
def local_path(self):
"""No slugs"""
- return self.get_remote_id().replace(f"https://{DOMAIN}", "")
+ return self.get_remote_id().replace(BASE_URL, "")
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""
@@ -100,10 +103,21 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
activity_serializer = activitypub.ShelfItem
collection_field = "shelf"
- def save(self, *args, priority=BROADCAST, **kwargs):
+ def save(
+ self,
+ *args,
+ priority=BROADCAST,
+ update_fields: Optional[Iterable[str]] = None,
+ **kwargs,
+ ):
if not self.user:
self.user = self.shelf.user
- if self.id and self.user.local:
+ update_fields = add_update_fields(update_fields, "user")
+
+ is_update = self.id is not None
+ super().save(*args, priority=priority, update_fields=update_fields, **kwargs)
+
+ if is_update and self.user.local:
# remove all caches related to all editions of this book
cache.delete_many(
[
@@ -111,7 +125,6 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
for book in self.book.parent_work.editions.all()
]
)
- super().save(*args, priority=priority, **kwargs)
def delete(self, *args, **kwargs):
if self.id and self.user.local:
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index a27c4b70d..6c2a73422 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -1,5 +1,6 @@
""" the particulars for this instance of BookWyrm """
import datetime
+from typing import Optional, Iterable
from urllib.parse import urljoin
import uuid
@@ -10,8 +11,12 @@ from django.dispatch import receiver
from django.utils import timezone
from model_utils import FieldTracker
+from bookwyrm.connectors.abstract_connector import get_data
from bookwyrm.preview_images import generate_site_preview_image_task
-from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
+from bookwyrm.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
+from bookwyrm.settings import RELEASE_API
+from bookwyrm.tasks import app, MISC
+from bookwyrm.utils.db import add_update_fields
from .base_model import BookWyrmModel, new_access_code
from .user import User
from .fields import get_absolute_url
@@ -45,7 +50,7 @@ class SiteSettings(SiteModel):
default_theme = models.ForeignKey(
"Theme", null=True, blank=True, on_delete=models.SET_NULL
)
- version = models.CharField(null=True, blank=True, max_length=10)
+ available_version = models.CharField(null=True, blank=True, max_length=10)
# admin setup options
install_mode = models.BooleanField(default=False)
@@ -96,6 +101,8 @@ class SiteSettings(SiteModel):
imports_enabled = models.BooleanField(default=True)
import_size_limit = models.IntegerField(default=0)
import_limit_reset = models.IntegerField(default=0)
+ user_exports_enabled = models.BooleanField(default=False)
+ user_import_time_limit = models.IntegerField(default=48)
field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
@@ -131,16 +138,19 @@ class SiteSettings(SiteModel):
return get_absolute_url(uploaded)
return urljoin(STATIC_FULL_URL, default_path)
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""if require_confirm_email is disabled, make sure no users are pending,
if enabled, make sure invite_question_text is not empty"""
+ if not self.invite_question_text:
+ self.invite_question_text = "What is your favourite book?"
+ update_fields = add_update_fields(update_fields, "invite_question_text")
+
+ super().save(*args, update_fields=update_fields, **kwargs)
+
if not self.require_confirm_email:
User.objects.filter(is_active=False, deactivation_reason="pending").update(
is_active=True, deactivation_reason=None
)
- if not self.invite_question_text:
- self.invite_question_text = "What is your favourite book?"
- super().save(*args, **kwargs)
class Theme(SiteModel):
@@ -149,6 +159,7 @@ class Theme(SiteModel):
created_date = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=50, unique=True)
path = models.CharField(max_length=50, unique=True)
+ loads = models.BooleanField(null=True, blank=True)
def __str__(self):
# pylint: disable=invalid-str-returned
@@ -182,7 +193,7 @@ class SiteInvite(models.Model):
@property
def link(self):
"""formats the invite link"""
- return f"https://{DOMAIN}/invite/{self.code}"
+ return f"{BASE_URL}/invite/{self.code}"
class InviteRequest(BookWyrmModel):
@@ -229,7 +240,7 @@ class PasswordReset(models.Model):
@property
def link(self):
"""formats the invite link"""
- return f"https://{DOMAIN}/password-reset/{self.code}"
+ return f"{BASE_URL}/password-reset/{self.code}"
# pylint: disable=unused-argument
@@ -242,3 +253,14 @@ def preview_image(instance, *args, **kwargs):
if len(changed_fields) > 0:
generate_site_preview_image_task.delay()
+
+
+@app.task(queue=MISC)
+def check_for_updates_task():
+ """See if git remote knows about a new version"""
+ site = SiteSettings.objects.get()
+ release = get_data(RELEASE_API, timeout=3)
+ available_version = release.get("tag_name", None)
+ if available_version:
+ site.available_version = available_version
+ site.save(update_fields=["available_version"])
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index e51f2ba07..2b357ebd2 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -1,5 +1,6 @@
""" models for storing different kinds of Activities """
from dataclasses import MISSING
+from typing import Optional, Iterable
import re
from django.apps import apps
@@ -11,12 +12,15 @@ from django.db.models import Q
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django.utils.translation import ngettext_lazy
from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.preview_images import generate_edition_preview_image_task
from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
@@ -77,19 +81,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""default sorting"""
ordering = ("-published_date",)
+ indexes = [
+ models.Index(fields=["remote_id"]),
+ models.Index(fields=["thread_id"]),
+ ]
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""save and notify"""
- if self.reply_parent:
+ if self.thread_id is None and self.reply_parent:
self.thread_id = self.reply_parent.thread_id or self.reply_parent_id
+ update_fields = add_update_fields(update_fields, "thread_id")
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
if not self.reply_parent:
self.thread_id = self.id
super().save(broadcast=False, update_fields=["thread_id"])
- def delete(self, *args, **kwargs): # pylint: disable=unused-argument
+ def delete(self, *args, **kwargs):
""" "delete" a status"""
if hasattr(self, "boosted_status"):
# okay but if it's a boost really delete it
@@ -101,19 +110,19 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if hasattr(self, "quotation"):
self.quotation = None # pylint: disable=attribute-defined-outside-init
self.deleted_date = timezone.now()
- self.save()
+ self.save(*args, **kwargs)
@property
def recipients(self):
"""tagged users who definitely need to get this status in broadcast"""
- mentions = [u for u in self.mention_users.all() if not u.local]
+ mentions = {u for u in self.mention_users.all() if not u.local}
if (
hasattr(self, "reply_parent")
and self.reply_parent
and not self.reply_parent.user.local
):
- mentions.append(self.reply_parent.user)
- return list(set(mentions))
+ mentions.add(self.reply_parent.user)
+ return list(mentions)
@classmethod
def ignore_activity(
@@ -177,6 +186,24 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""you can't boost dms"""
return self.privacy in ["unlisted", "public"]
+ @property
+ def page_title(self):
+ """title of the page when only this status is shown"""
+ return _("%(display_name)s's status") % {"display_name": self.user.display_name}
+
+ @property
+ def page_description(self):
+ """description of the page in meta tags when only this status is shown"""
+ return None
+
+ @property
+ def page_image(self):
+ """image to use as preview in meta tags when only this status is shown"""
+ if self.mention_books.exists():
+ book = self.mention_books.first()
+ return book.preview_image or book.cover
+ return self.user.preview_image
+
def to_replies(self, **kwargs):
"""helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection(
@@ -186,7 +213,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs,
).serialize()
- def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
+ def to_activity_dataclass(self, pure=False):
"""return tombstone if the status is deleted"""
if self.deleted:
return activitypub.Tombstone(
@@ -269,7 +296,7 @@ class GeneratedNote(Status):
"""indicate the book in question for mastodon (or w/e) users"""
message = self.content
books = ", ".join(
- f'"{book.title}"'
+ f'{book.title}'
for book in self.mention_books.all()
)
return f"{self.user.display_name} {message} {books}"
@@ -300,6 +327,10 @@ class BookStatus(Status):
abstract = True
+ @property
+ def page_image(self):
+ return self.book.preview_image or self.book.cover or super().page_image
+
class Comment(BookStatus):
"""like a review but without a rating and transient"""
@@ -320,31 +351,37 @@ class Comment(BookStatus):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
- if self.progress_mode == "PG" and self.progress and (self.progress > 0):
- return_value = (
- f'{self.content}(comment on '
- f'"{self.book.title}", page {self.progress})
'
- )
- else:
- return_value = (
- f'{self.content}(comment on '
- f'"{self.book.title}")
'
- )
- return return_value
+ progress = self.progress or 0
+ citation = (
+ f'comment on '
+ f"{self.book.title}"
+ )
+ if self.progress_mode == "PG" and progress > 0:
+ citation += f", p. {progress}"
+ return f"{self.content}({citation})
"
activity_serializer = activitypub.Comment
+ @property
+ def page_title(self):
+ return _("%(display_name)s's comment on %(book_title)s") % {
+ "display_name": self.user.display_name,
+ "book_title": self.book.title,
+ }
+
class Quotation(BookStatus):
"""like a review but without a rating and transient"""
quote = fields.HtmlField()
raw_quote = models.TextField(blank=True, null=True)
- position = models.IntegerField(
- validators=[MinValueValidator(0)], null=True, blank=True
+ position = models.TextField(
+ null=True,
+ blank=True,
)
- endposition = models.IntegerField(
- validators=[MinValueValidator(0)], null=True, blank=True
+ endposition = models.TextField(
+ null=True,
+ blank=True,
)
position_mode = models.CharField(
max_length=3,
@@ -354,25 +391,35 @@ class Quotation(BookStatus):
blank=True,
)
+ def _format_position(self) -> Optional[str]:
+ """serialize page position"""
+ beg = self.position
+ end = self.endposition
+ if self.position_mode != "PG" or not beg:
+ return None
+ return f"pp. {beg}-{end}" if end else f"p. {beg}"
+
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^", '
"', self.quote)
quote = re.sub(r"
$", '"', quote)
- if self.position_mode == "PG" and self.position and (self.position > 0):
- return_value = (
- f'{quote} -- '
- f'"{self.book.title}", page {self.position}
{self.content}'
- )
- else:
- return_value = (
- f'{quote} -- '
- f'"{self.book.title}"
{self.content}'
- )
- return return_value
+ title, href = self.book.title, self.book.remote_id
+ author = f"{name}: " if (name := self.book.author_text) else ""
+ citation = f'— {author}{title}'
+ if position := self._format_position():
+ citation += f", {position}"
+ return f"{quote} {citation}
{self.content}"
activity_serializer = activitypub.Quotation
+ @property
+ def page_title(self):
+ return _("%(display_name)s's quote from %(book_title)s") % {
+ "display_name": self.user.display_name,
+ "book_title": self.book.title,
+ }
+
class Review(BookStatus):
"""a book review"""
@@ -402,14 +449,22 @@ class Review(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
return self.content
+ @property
+ def page_title(self):
+ return _("%(display_name)s's review of %(book_title)s") % {
+ "display_name": self.user.display_name,
+ "book_title": self.book.title,
+ }
+
activity_serializer = activitypub.Review
pure_type = "Article"
def save(self, *args, **kwargs):
"""clear rating caches"""
+ super().save(*args, **kwargs)
+
if self.book.parent_work:
cache.delete(f"book-rating-{self.book.parent_work.id}")
- super().save(*args, **kwargs)
class ReviewRating(Review):
@@ -425,6 +480,18 @@ class ReviewRating(Review):
template = get_template("snippets/generated_status/rating.html")
return template.render({"book": self.book, "rating": self.rating}).strip()
+ @property
+ def page_description(self):
+ return ngettext_lazy(
+ "%(display_name)s rated %(book_title)s: %(display_rating).1f star",
+ "%(display_name)s rated %(book_title)s: %(display_rating).1f stars",
+ "display_rating",
+ ) % {
+ "display_name": self.user.display_name,
+ "book_title": self.book.title,
+ "display_rating": self.rating,
+ }
+
activity_serializer = activitypub.Rating
pure_type = "Note"
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 6e0912aec..80f1c2d9a 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -1,27 +1,31 @@
""" database schema for user data """
+import datetime
import re
+import zoneinfo
+from typing import Optional, Iterable
from urllib.parse import urlparse
+from uuid import uuid4
from django.apps import apps
from django.contrib.auth.models import AbstractUser
-from django.contrib.postgres.fields import ArrayField, CICharField
+from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import PermissionDenied, ObjectDoesNotExist
from django.dispatch import receiver
-from django.db import models, transaction
+from django.db import models, transaction, IntegrityError
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
-import pytz
from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
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.settings import BASE_URL, ENABLE_PREVIEW_IMAGES, LANGUAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app, MISC
from bookwyrm.utils import regex
+from bookwyrm.utils.db import add_update_fields
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
from .federated_server import FederatedServer
@@ -41,18 +45,13 @@ def get_feed_filter_choices():
return [f[0] for f in FeedFilterChoices]
-def site_link():
- """helper for generating links to the site"""
- protocol = "https" if USE_HTTPS else "http"
- return f"{protocol}://{DOMAIN}"
-
-
# pylint: disable=too-many-public-methods
class User(OrderedCollectionPageMixin, AbstractUser):
"""a user who wants to read books"""
username = fields.UsernameField()
email = models.EmailField(unique=True, null=True)
+ is_deleted = models.BooleanField(default=False)
key_pair = fields.OneToOneField(
"KeyPair",
@@ -79,11 +78,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True)
- localname = CICharField(
+ localname = models.CharField(
max_length=255,
null=True,
unique=True,
validators=[fields.validate_localname],
+ db_collation="case_insensitive",
)
# name is your display name, which you can change at will
name = fields.CharField(max_length=100, null=True, blank=True)
@@ -140,6 +140,19 @@ class User(OrderedCollectionPageMixin, AbstractUser):
theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
hide_follows = fields.BooleanField(default=False)
+ # migration fields
+
+ moved_to = fields.RemoteIdField(
+ null=True, unique=False, activitypub_field="movedTo", deduplication_field=False
+ )
+ also_known_as = fields.ManyToManyField(
+ "self",
+ symmetrical=False,
+ unique=False,
+ activitypub_field="alsoKnownAs",
+ deduplication_field=False,
+ )
+
# options to turn features on and off
show_goal = models.BooleanField(default=True)
show_suggested_users = models.BooleanField(default=True)
@@ -147,7 +160,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_guided_tour = models.BooleanField(default=True)
# feed options
- feed_status_types = ArrayField(
+ feed_status_types = DjangoArrayField(
models.CharField(max_length=10, blank=False, choices=FeedFilterChoices),
size=8,
default=get_feed_filter_choices,
@@ -156,8 +169,8 @@ class User(OrderedCollectionPageMixin, AbstractUser):
summary_keys = models.JSONField(null=True)
preferred_timezone = models.CharField(
- choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
- default=str(pytz.utc),
+ choices=[(str(tz), str(tz)) for tz in sorted(zoneinfo.available_timezones())],
+ default=str(datetime.timezone.utc),
max_length=255,
)
preferred_language = models.CharField(
@@ -183,6 +196,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
hotp_count = models.IntegerField(default=0, blank=True, null=True)
+ class Meta(AbstractUser.Meta):
+ """indexes"""
+
+ indexes = [
+ models.Index(fields=["username"]),
+ models.Index(fields=["is_active", "local"]),
+ ]
+
@property
def active_follower_requests(self):
"""Follow requests from active users"""
@@ -191,8 +212,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
@property
def confirmation_link(self):
"""helper for generating confirmation links"""
- link = site_link()
- return f"{link}/confirm-email/{self.confirmation_code}"
+ return f"{BASE_URL}/confirm-email/{self.confirmation_code}"
@property
def following_link(self):
@@ -311,20 +331,24 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "Hashtag": "as:Hashtag",
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
+ "alsoKnownAs": {"@id": "as:alsoKnownAs", "@type": "@id"},
+ "movedTo": {"@id": "as:movedTo", "@type": "@id"},
},
]
return activity_object
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""populate fields for new local users"""
created = not bool(self.id)
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
- self.username = f"{self.username}@{actor_parts.netloc}"
+ self.username = f"{self.username}@{actor_parts.hostname}"
+ update_fields = add_update_fields(update_fields, "username")
# this user already exists, no need to populate fields
if not created:
@@ -333,26 +357,34 @@ class User(OrderedCollectionPageMixin, AbstractUser):
elif not self.deactivation_date:
self.deactivation_date = timezone.now()
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
return
# this is a new remote user, we need to set their remote server field
if not self.local:
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
transaction.on_commit(lambda: set_remote_server(self.id))
return
with transaction.atomic():
# populate fields for local users
- link = site_link()
- self.remote_id = f"{link}/user/{self.localname}"
+ self.remote_id = f"{BASE_URL}/user/{self.localname}"
self.followers_url = f"{self.remote_id}/followers"
self.inbox = f"{self.remote_id}/inbox"
- self.shared_inbox = f"{link}/inbox"
+ self.shared_inbox = f"{BASE_URL}/inbox"
self.outbox = f"{self.remote_id}/outbox"
+ update_fields = add_update_fields(
+ update_fields,
+ "remote_id",
+ "followers_url",
+ "inbox",
+ "shared_inbox",
+ "outbox",
+ )
+
# an id needs to be set before we can proceed with related models
- super().save(*args, **kwargs)
+ super().save(*args, update_fields=update_fields, **kwargs)
# make users editors by default
try:
@@ -377,15 +409,48 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def delete(self, *args, **kwargs):
"""We don't actually delete the database entry"""
- # pylint: disable=attribute-defined-outside-init
self.is_active = False
- self.avatar = ""
+ self.allow_reactivation = False
+ self.is_deleted = True
+
+ self.erase_user_data()
+ self.erase_user_statuses()
+
# skip the logic in this class's save()
- super().save(*args, **kwargs)
+ super().save(
+ *args,
+ **kwargs,
+ )
+
+ def erase_user_data(self):
+ """Wipe a user's custom data"""
+ if not self.is_deleted:
+ raise IntegrityError(
+ "Trying to erase user data on user that is not deleted"
+ )
+
+ # mangle email address
+ self.email = f"{uuid4()}@deleted.user"
+
+ # erase data fields
+ self.avatar = ""
+ self.preview_image = ""
+ self.summary = None
+ self.name = None
+ self.favorites.set([])
+
+ def erase_user_statuses(self, broadcast=True):
+ """Wipe the data on all the user's statuses"""
+ if not self.is_deleted:
+ raise IntegrityError(
+ "Trying to erase user data on user that is not deleted"
+ )
+
+ for status in self.status_set.all():
+ status.delete(broadcast=broadcast)
def deactivate(self):
"""Disable the user but allow them to reactivate"""
- # pylint: disable=attribute-defined-outside-init
self.is_active = False
self.deactivation_reason = "self_deactivation"
self.allow_reactivation = True
@@ -393,7 +458,6 @@ 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
@@ -457,18 +521,44 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
activity_serializer = activitypub.PublicKey
serialize_reverse_fields = [("owner", "owner", "id")]
+ class Meta:
+ """indexes"""
+
+ indexes = [
+ models.Index(fields=["remote_id"]),
+ ]
+
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
return f"{self.owner.remote_id}/#main-key"
- def save(self, *args, **kwargs):
+ def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
"""create a key pair"""
# no broadcasting happening here
if "broadcast" in kwargs:
del kwargs["broadcast"]
+
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
- return super().save(*args, **kwargs)
+ update_fields = add_update_fields(
+ update_fields, "private_key", "public_key"
+ )
+
+ super().save(*args, update_fields=update_fields, **kwargs)
+
+
+@app.task(queue=MISC)
+def erase_user_data(user_id):
+ """Erase any custom data about this user asynchronously
+ This is for deleted historical user data that pre-dates data
+ being cleared automatically"""
+ user = User.objects.get(id=user_id)
+ user.erase_user_data()
+ user.save(
+ broadcast=False,
+ update_fields=["email", "avatar", "preview_image", "summary", "name"],
+ )
+ user.erase_user_statuses(broadcast=False)
@app.task(queue=MISC)
@@ -477,7 +567,7 @@ def set_remote_server(user_id, allow_external_connections=False):
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
federated_server = get_or_create_remote_server(
- actor_parts.netloc, allow_external_connections=allow_external_connections
+ actor_parts.hostname, 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:
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
index aba372abc..66d2e2d4b 100644
--- a/bookwyrm/preview_images.py
+++ b/bookwyrm/preview_images.py
@@ -1,4 +1,5 @@
""" Generate social media preview images for twitter/mastodon/etc """
+
import math
import os
import textwrap
@@ -42,8 +43,8 @@ def get_imagefont(name, size):
return ImageFont.truetype(path, size)
except KeyError:
logger.error("Font %s not found in config", name)
- except OSError:
- logger.error("Could not load font %s from file", name)
+ except OSError as err:
+ logger.error("Could not load font %s from file: %s", name, err)
return ImageFont.load_default()
@@ -59,7 +60,7 @@ def get_font(weight, size=28):
font.set_variation_by_name("Bold")
if weight == "regular":
font.set_variation_by_name("Regular")
- except AttributeError:
+ except OSError:
pass
return font
@@ -174,11 +175,13 @@ def generate_instance_layer(content_width):
site = models.SiteSettings.objects.get()
if site.logo_small:
- logo_img = Image.open(site.logo_small)
+ with Image.open(site.logo_small) as logo_img:
+ logo_img.load()
else:
try:
static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
- logo_img = Image.open(static_path)
+ with Image.open(static_path) as logo_img:
+ logo_img.load()
except FileNotFoundError:
logo_img = None
@@ -210,18 +213,9 @@ def generate_instance_layer(content_width):
def generate_rating_layer(rating, content_width):
"""Places components for rating preview"""
- try:
- icon_star_full = Image.open(
- os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
- )
- icon_star_empty = Image.open(
- os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
- )
- icon_star_half = Image.open(
- os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
- )
- except FileNotFoundError:
- return None
+ path_star_full = os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
+ path_star_empty = os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
+ path_star_half = os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
icon_size = 64
icon_margin = 10
@@ -236,17 +230,23 @@ def generate_rating_layer(rating, content_width):
position_x = 0
- for _ in range(math.floor(rating)):
- rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
- position_x = position_x + icon_size + icon_margin
+ try:
+ with Image.open(path_star_full) as icon_star_full:
+ for _ in range(math.floor(rating)):
+ rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
- if math.floor(rating) != math.ceil(rating):
- rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
- position_x = position_x + icon_size + icon_margin
+ if math.floor(rating) != math.ceil(rating):
+ with Image.open(path_star_half) as icon_star_half:
+ rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
- for _ in range(5 - math.ceil(rating)):
- rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
- position_x = position_x + icon_size + icon_margin
+ with Image.open(path_star_empty) as icon_star_empty:
+ for _ in range(5 - math.ceil(rating)):
+ rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
+ except FileNotFoundError:
+ return None
rating_layer_mask = rating_layer_mask.getchannel("A")
rating_layer_mask = ImageOps.invert(rating_layer_mask)
@@ -289,7 +289,8 @@ def generate_preview_image(
texts = texts or {}
# Cover
try:
- inner_img_layer = Image.open(picture)
+ with Image.open(picture) as inner_img_layer:
+ inner_img_layer.load()
inner_img_layer.thumbnail(
(inner_img_width, inner_img_height), Image.Resampling.LANCZOS
)
@@ -419,7 +420,6 @@ def save_and_cleanup(image, instance=None):
return True
-# pylint: disable=invalid-name
@app.task(queue=IMAGES)
def generate_site_preview_image_task():
"""generate preview_image for the website"""
@@ -444,7 +444,6 @@ def generate_site_preview_image_task():
save_and_cleanup(image, instance=site)
-# pylint: disable=invalid-name
@app.task(queue=IMAGES)
def generate_edition_preview_image_task(book_id):
"""generate preview_image for a book"""
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 042cd8cf8..6da6f4bae 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -1,7 +1,10 @@
""" bookwyrm settings and configuration """
import os
+from typing import AnyStr
+
from environs import Env
+
import requests
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ImproperlyConfigured
@@ -12,7 +15,12 @@ from django.core.exceptions import ImproperlyConfigured
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
-VERSION = "0.6.4"
+
+with open("VERSION", encoding="utf-8") as f:
+ version = f.read()
+ version = version.replace("\n", "")
+
+VERSION = version
RELEASE_API = env(
"RELEASE_API",
@@ -21,8 +29,11 @@ RELEASE_API = env(
PAGE_LENGTH = env.int("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
+# TODO: extend maximum age to 1 year once termination of active sessions
+# is implemented (see bookwyrm-social#2278, bookwyrm-social#3082).
+SESSION_COOKIE_AGE = env.int("SESSION_COOKIE_AGE", 3600 * 24 * 30) # 1 month
-JS_CACHE = "b972a43c"
+JS_CACHE = "8a89cad7"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@@ -37,7 +48,7 @@ EMAIL_SENDER_DOMAIN = env("EMAIL_SENDER_DOMAIN", DOMAIN)
EMAIL_SENDER = f"{EMAIL_SENDER_NAME}@{EMAIL_SENDER_DOMAIN}"
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BASE_DIR: AnyStr = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LOCALE_PATHS = [
os.path.join(BASE_DIR, "locale"),
]
@@ -90,11 +101,14 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
+ "oauth2_provider",
+ "file_resubmit",
"sass_processor",
"bookwyrm",
"celery",
"django_celery_beat",
"imagekit",
+ "pgtrigger",
"storages",
]
@@ -110,6 +124,7 @@ MIDDLEWARE = [
"bookwyrm.middleware.IPBlocklistMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "bookwyrm.middleware.FileTooBig",
]
ROOT_URLCONF = "bookwyrm.urls"
@@ -233,17 +248,22 @@ if env.bool("USE_DUMMY_CACHE", False):
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.dummy.DummyCache",
- }
+ },
+ "file_resubmit": {
+ "BACKEND": "django.core.cache.backends.dummy.DummyCache",
+ "LOCATION": "/tmp/file_resubmit_tests/",
+ },
}
else:
CACHES = {
"default": {
- "BACKEND": "django_redis.cache.RedisCache",
+ "BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": REDIS_ACTIVITY_URL,
- "OPTIONS": {
- "CLIENT_CLASS": "django_redis.client.DefaultClient",
- },
- }
+ },
+ "file_resubmit": {
+ "BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
+ "LOCATION": "/tmp/file_resubmit/",
+ },
}
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
@@ -299,105 +319,191 @@ LANGUAGES = [
("eu-es", _("Euskara (Basque)")),
("gl-es", _("Galego (Galician)")),
("it-it", _("Italiano (Italian)")),
+ ("ko-kr", _("한국어 (Korean)")),
("fi-fi", _("Suomi (Finnish)")),
("fr-fr", _("Français (French)")),
("lt-lt", _("Lietuvių (Lithuanian)")),
+ ("nl-nl", _("Nederlands (Dutch)")),
("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)")),
+ ("uk-ua", _("Українська (Ukrainian)")),
("zh-hans", _("简体中文 (Simplified Chinese)")),
("zh-hant", _("繁體中文 (Traditional Chinese)")),
]
LANGUAGE_ARTICLES = {
"English": {"the", "a", "an"},
+ "Español (Spanish)": {"un", "una", "unos", "unas", "el", "la", "los", "las"},
}
TIME_ZONE = "UTC"
USE_I18N = True
-USE_L10N = True
-
USE_TZ = True
-
-agent = requests.utils.default_user_agent()
-USER_AGENT = f"{agent} (BookWyrm/{VERSION}; +https://{DOMAIN}/)"
-
# Imagekit generated thumbnails
ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False)
IMAGEKIT_CACHEFILE_DIR = "thumbnails"
IMAGEKIT_DEFAULT_CACHEFILE_STRATEGY = "bookwyrm.thumbnail_generation.Strategy"
-# Static files (CSS, JavaScript, Images)
-# 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
+PORT = env.int("PORT", 443 if USE_HTTPS else 80)
+if (USE_HTTPS and PORT == 443) or (not USE_HTTPS and PORT == 80):
+ NETLOC = DOMAIN
+else:
+ NETLOC = f"{DOMAIN}:{PORT}"
+BASE_URL = f"{PROTOCOL}://{NETLOC}"
+CSRF_TRUSTED_ORIGINS = [BASE_URL]
+
+USER_AGENT = f"BookWyrm (BookWyrm/{VERSION}; +{BASE_URL})"
+
+# Storage
+
USE_S3 = env.bool("USE_S3", False)
USE_AZURE = env.bool("USE_AZURE", False)
+S3_SIGNED_URL_EXPIRY = env.int("S3_SIGNED_URL_EXPIRY", 900)
if USE_S3:
# AWS settings
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY")
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
- AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN")
+ AWS_S3_CUSTOM_DOMAIN = env("AWS_S3_CUSTOM_DOMAIN", None)
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", "")
- AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL")
+ AWS_S3_ENDPOINT_URL = env("AWS_S3_ENDPOINT_URL", None)
AWS_DEFAULT_ACL = "public-read"
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
+ AWS_S3_URL_PROTOCOL = env("AWS_S3_URL_PROTOCOL", f"{PROTOCOL}:")
+ # Storages
+ STORAGES = {
+ "default": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "images",
+ "default_acl": "public-read",
+ "file_overwrite": False,
+ },
+ },
+ "staticfiles": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "static",
+ "default_acl": "public-read",
+ },
+ },
+ "sass_processor": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "static",
+ "default_acl": "public-read",
+ },
+ },
+ "exports": {
+ "BACKEND": "storages.backends.s3.S3Storage",
+ "OPTIONS": {
+ "location": "images",
+ "default_acl": None,
+ "file_overwrite": False,
+ },
+ },
+ }
# S3 Static settings
STATIC_LOCATION = "static"
- STATIC_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
- STATICFILES_STORAGE = "bookwyrm.storage_backends.StaticStorage"
+ STATIC_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{STATIC_LOCATION}/"
+ STATIC_FULL_URL = STATIC_URL
# S3 Media settings
MEDIA_LOCATION = "images"
- MEDIA_URL = f"{PROTOCOL}://{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
+ MEDIA_URL = f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}/{MEDIA_LOCATION}/"
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
+ # Content Security Policy
+ CSP_DEFAULT_SRC = [
+ "'self'",
+ f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}"
+ if AWS_S3_CUSTOM_DOMAIN
+ else None,
+ ] + CSP_ADDITIONAL_HOSTS
+ CSP_SCRIPT_SRC = [
+ "'self'",
+ f"{AWS_S3_URL_PROTOCOL}//{AWS_S3_CUSTOM_DOMAIN}"
+ if AWS_S3_CUSTOM_DOMAIN
+ else None,
+ ] + CSP_ADDITIONAL_HOSTS
elif USE_AZURE:
+ # Azure settings
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")
+ # Storages
+ STORAGES = {
+ "default": {
+ "BACKEND": "storages.backends.azure_storage.AzureStorage",
+ "OPTIONS": {
+ "location": "images",
+ "overwrite_files": False,
+ },
+ },
+ "staticfiles": {
+ "BACKEND": "storages.backends.azure_storage.AzureStorage",
+ "OPTIONS": {
+ "location": "static",
+ },
+ },
+ "exports": {
+ "BACKEND": None, # not implemented yet
+ },
+ }
# Azure Static settings
STATIC_LOCATION = "static"
STATIC_URL = (
f"{PROTOCOL}://{AZURE_CUSTOM_DOMAIN}/{AZURE_CONTAINER}/{STATIC_LOCATION}/"
)
- STATICFILES_STORAGE = "bookwyrm.storage_backends.AzureStaticStorage"
+ STATIC_FULL_URL = STATIC_URL
# 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"
+ # Content Security Policy
CSP_DEFAULT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'", AZURE_CUSTOM_DOMAIN] + CSP_ADDITIONAL_HOSTS
else:
+ # Storages
+ STORAGES = {
+ "default": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
+ },
+ "exports": {
+ "BACKEND": "django.core.files.storage.FileSystemStorage",
+ "OPTIONS": {
+ "location": "exports",
+ },
+ },
+ }
+ # Static settings
STATIC_URL = "/static/"
+ STATIC_FULL_URL = BASE_URL + STATIC_URL
+ # Media settings
MEDIA_URL = "/images/"
- MEDIA_FULL_URL = f"{PROTOCOL}://{DOMAIN}{MEDIA_URL}"
- STATIC_FULL_URL = f"{PROTOCOL}://{DOMAIN}{STATIC_URL}"
+ MEDIA_FULL_URL = BASE_URL + MEDIA_URL
+ # Content Security Policy
CSP_DEFAULT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS
CSP_SCRIPT_SRC = ["'self'"] + CSP_ADDITIONAL_HOSTS
@@ -420,3 +526,7 @@ if HTTP_X_FORWARDED_PROTO:
# 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"
+
+# We only allow specifying DATA_UPLOAD_MAX_MEMORY_SIZE in MiB from .env
+# (note the difference in variable names).
+DATA_UPLOAD_MAX_MEMORY_SIZE = env.int("DATA_UPLOAD_MAX_MEMORY_MiB", 100) << 20
diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py
index 08780b731..f59367b51 100644
--- a/bookwyrm/signatures.py
+++ b/bookwyrm/signatures.py
@@ -6,7 +6,7 @@ from base64 import b64encode, b64decode
from Crypto import Random
from Crypto.PublicKey import RSA
-from Crypto.Signature import pkcs1_15 # pylint: disable=no-name-in-module
+from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
MAX_SIGNATURE_AGE = 300
@@ -84,7 +84,6 @@ class Signature:
self.headers = headers
self.signature = signature
- # pylint: disable=invalid-name
@classmethod
def parse(cls, request):
"""extract and parse a signature from an http request"""
diff --git a/bookwyrm/static/css/bookwyrm.scss b/bookwyrm/static/css/bookwyrm.scss
index 437795457..17d6d9119 100644
--- a/bookwyrm/static/css/bookwyrm.scss
+++ b/bookwyrm/static/css/bookwyrm.scss
@@ -1,4 +1,3 @@
@charset "utf-8";
-
-@import "vendor/bulma/bulma.sass";
-@import "bookwyrm/all.scss";
+@import "vendor/bulma/bulma";
+@import "bookwyrm/all";
diff --git a/bookwyrm/static/css/bookwyrm/_all.scss b/bookwyrm/static/css/bookwyrm/_all.scss
index 1e8569827..7e50fe3e1 100644
--- a/bookwyrm/static/css/bookwyrm/_all.scss
+++ b/bookwyrm/static/css/bookwyrm/_all.scss
@@ -16,9 +16,7 @@
@import "components/status";
@import "components/tabs";
@import "components/toggle";
-
@import "overrides/bulma_overrides";
-
@import "utilities/a11y";
@import "utilities/alignments";
@import "utilities/colors";
@@ -40,10 +38,12 @@ body {
width: 12px;
height: 12px;
}
+
::-webkit-scrollbar-thumb {
background: $scrollbar-thumb;
border-radius: 0.5em;
}
+
::-webkit-scrollbar-track {
background: $scrollbar-track;
}
@@ -89,7 +89,6 @@ button::-moz-focus-inner {
/** Utilities not covered by Bulma
******************************************************************************/
-
.tag.is-small {
height: auto;
}
@@ -144,7 +143,6 @@ button.button-paragraph {
vertical-align: middle;
}
-
/** States
******************************************************************************/
@@ -159,7 +157,6 @@ button.button-paragraph {
cursor: not-allowed;
}
-
/* Notifications page
******************************************************************************/
diff --git a/bookwyrm/static/css/bookwyrm/components/_book_cover.scss b/bookwyrm/static/css/bookwyrm/components/_book_cover.scss
index 48b564a0b..db9391cc1 100644
--- a/bookwyrm/static/css/bookwyrm/components/_book_cover.scss
+++ b/bookwyrm/static/css/bookwyrm/components/_book_cover.scss
@@ -43,7 +43,7 @@
max-height: 100%;
/* Useful when stretching under-sized images. */
- image-rendering: optimizeQuality;
+ image-rendering: optimizequality;
image-rendering: smooth;
}
diff --git a/bookwyrm/static/css/bookwyrm/components/_copy.scss b/bookwyrm/static/css/bookwyrm/components/_copy.scss
index e0c4246e6..2595401cc 100644
--- a/bookwyrm/static/css/bookwyrm/components/_copy.scss
+++ b/bookwyrm/static/css/bookwyrm/components/_copy.scss
@@ -28,3 +28,31 @@
.vertical-copy button {
width: 100%;
}
+
+.copy-tooltip {
+ overflow: visible;
+ visibility: hidden;
+ width: 140px;
+ background-color: #555;
+ color: #fff;
+ text-align: center;
+ border-radius: 6px;
+ padding: 5px;
+ position: absolute;
+ z-index: 1;
+ margin-left: -30px;
+ margin-top: -45px;
+ opacity: 0;
+ transition: opacity 0.3s;
+}
+
+.copy-tooltip::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ margin-left: -60px;
+ border-width: 5px;
+ border-style: solid;
+ border-color: #555 transparent transparent;
+}
diff --git a/bookwyrm/static/css/bookwyrm/components/_tabs.scss b/bookwyrm/static/css/bookwyrm/components/_tabs.scss
index 2d68a383b..8e00f6a88 100644
--- a/bookwyrm/static/css/bookwyrm/components/_tabs.scss
+++ b/bookwyrm/static/css/bookwyrm/components/_tabs.scss
@@ -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 {
diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot
index 69628662b..33dc07eec 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.eot and b/bookwyrm/static/css/fonts/icomoon.eot differ
diff --git a/bookwyrm/static/css/fonts/icomoon.svg b/bookwyrm/static/css/fonts/icomoon.svg
index c67c8b225..058c19226 100644
--- a/bookwyrm/static/css/fonts/icomoon.svg
+++ b/bookwyrm/static/css/fonts/icomoon.svg
@@ -39,9 +39,12 @@
+
+
+
diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf
index 12c79d551..89d3be8fa 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.ttf and b/bookwyrm/static/css/fonts/icomoon.ttf differ
diff --git a/bookwyrm/static/css/fonts/icomoon.woff b/bookwyrm/static/css/fonts/icomoon.woff
index 624b70f33..95325ab4a 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.woff and b/bookwyrm/static/css/fonts/icomoon.woff differ
diff --git a/bookwyrm/static/css/themes/bookwyrm-dark.scss b/bookwyrm/static/css/themes/bookwyrm-dark.scss
index c3e8655e3..f5e08e9a0 100644
--- a/bookwyrm/static/css/themes/bookwyrm-dark.scss
+++ b/bookwyrm/static/css/themes/bookwyrm-dark.scss
@@ -1,4 +1,4 @@
-@import "../vendor/bulma/sass/utilities/initial-variables.sass";
+@import "../vendor/bulma/sass/utilities/initial-variables";
/* Colors
******************************************************************************/
@@ -16,7 +16,7 @@ $danger-light: #481922;
$light: #393939;
$red: #ffa1b4;
$black: #000;
-$white-ter: hsl(0, 0%, 90%);
+$white-ter: hsl(0deg, 0%, 90%);
/* book cover standins */
$no-cover-color: #002549;
@@ -79,7 +79,7 @@ $info-dark: #72b6ee;
}
/* misc */
-$shadow: 0 0.5em 0.5em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02);
+$shadow: 0 0.5em 0.5em -0.125em rgba($black, 0.2), 0 0 0 1px rgba($black, 0.02);
$card-header-shadow: 0 0.125em 0.25em rgba($black, 0.1);
$invisible-overlay-background-color: rgba($black, 0.66);
$progress-value-background-color: $border-light;
@@ -97,27 +97,23 @@ $family-secondary: $family-sans-serif;
color: $grey-light !important;
}
-
.tabs li:not(.is-active) a {
color: #2e7eb9 !important;
}
- .tabs li:not(.is-active) a:hover {
+
+.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 ;
+ border-bottom-color: #e6e6e6 !important;
}
-
#qrcode svg {
background-color: #a6a6a6;
}
-@import "../bookwyrm.scss";
+@import "../bookwyrm";
@import "../vendor/icons.css";
-@import "../vendor/shepherd.scss";
+@import "../vendor/shepherd";
diff --git a/bookwyrm/static/css/themes/bookwyrm-light.scss b/bookwyrm/static/css/themes/bookwyrm-light.scss
index bb7d340a9..37e990127 100644
--- a/bookwyrm/static/css/themes/bookwyrm-light.scss
+++ b/bookwyrm/static/css/themes/bookwyrm-light.scss
@@ -1,4 +1,4 @@
-@import "../vendor/bulma/sass/utilities/derived-variables.sass";
+@import "../vendor/bulma/sass/utilities/derived-variables";
/* Colors
******************************************************************************/
@@ -68,19 +68,16 @@ $family-secondary: $family-sans-serif;
.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:not(.is-active) a:hover {
+ border-bottom-color: #3273dc !important;
}
+
.tabs li.is-active a {
color: #4a4a4a !important;
- border-bottom-color: #4a4a4a !important ;
+ border-bottom-color: #4a4a4a !important;
}
-
-@import "../bookwyrm.scss";
+@import "../bookwyrm";
@import "../vendor/icons.css";
-@import "../vendor/shepherd.scss";
+@import "../vendor/shepherd";
diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css
index 6477aee5c..6af5c2813 100644
--- a/bookwyrm/static/css/vendor/icons.css
+++ b/bookwyrm/static/css/vendor/icons.css
@@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
- src: url('../fonts/icomoon.eot?r7jc98');
- src: url('../fonts/icomoon.eot?r7jc98#iefix') format('embedded-opentype'),
- url('../fonts/icomoon.ttf?r7jc98') format('truetype'),
- url('../fonts/icomoon.woff?r7jc98') format('woff'),
- url('../fonts/icomoon.svg?r7jc98#icomoon') format('svg');
+ src: url('../fonts/icomoon.eot?nr4nq7');
+ src: url('../fonts/icomoon.eot?nr4nq7#iefix') format('embedded-opentype'),
+ url('../fonts/icomoon.ttf?nr4nq7') format('truetype'),
+ url('../fonts/icomoon.woff?nr4nq7') format('woff'),
+ url('../fonts/icomoon.svg?nr4nq7#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@@ -122,6 +122,9 @@
.icon-graphic-banknote:before {
content: "\e920";
}
+.icon-copy:before {
+ content: "\e92c";
+}
.icon-search:before {
content: "\e986";
}
@@ -152,3 +155,9 @@
.icon-barcode:before {
content: "\e937";
}
+.icon-eye:before {
+ content: "\e9ce";
+}
+.icon-eye-blocked:before {
+ content: "\e9d1";
+}
diff --git a/bookwyrm/static/js/autocomplete.js b/bookwyrm/static/js/autocomplete.js
index 84474e43c..6836d356d 100644
--- a/bookwyrm/static/js/autocomplete.js
+++ b/bookwyrm/static/js/autocomplete.js
@@ -106,11 +106,15 @@ const tries = {
e: {
p: {
u: {
- b: "ePub",
+ b: "EPUB",
},
},
},
f: {
+ b: {
+ 2: "FB2",
+ 3: "FB3",
+ },
l: {
a: {
c: "FLAC",
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index 0c6958f33..a2351a98c 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -30,6 +30,12 @@ let BookWyrm = new (class {
.querySelectorAll("[data-back]")
.forEach((button) => button.addEventListener("click", this.back));
+ document
+ .querySelectorAll("[data-password-icon]")
+ .forEach((button) =>
+ button.addEventListener("click", this.togglePasswordVisibility.bind(this))
+ );
+
document
.querySelectorAll('input[type="file"]')
.forEach((node) => node.addEventListener("change", this.disableIfTooLarge.bind(this)));
@@ -65,6 +71,9 @@ let BookWyrm = new (class {
.querySelectorAll('input[type="file"]')
.forEach(bookwyrm.disableIfTooLarge.bind(bookwyrm));
document.querySelectorAll("[data-copytext]").forEach(bookwyrm.copyText.bind(bookwyrm));
+ document
+ .querySelectorAll("[data-copywithtooltip]")
+ .forEach(bookwyrm.copyWithTooltip.bind(bookwyrm));
document
.querySelectorAll(".modal.is-active")
.forEach(bookwyrm.handleActiveModal.bind(bookwyrm));
@@ -524,6 +533,21 @@ let BookWyrm = new (class {
textareaEl.parentNode.appendChild(copyButtonEl);
}
+ copyWithTooltip(copyButtonEl) {
+ const text = document.getElementById(copyButtonEl.dataset.contentId).innerHTML;
+ const tooltipEl = document.getElementById(copyButtonEl.dataset.tooltipId);
+
+ copyButtonEl.addEventListener("click", () => {
+ navigator.clipboard.writeText(text);
+ tooltipEl.style.visibility = "visible";
+ tooltipEl.style.opacity = 1;
+ setTimeout(function () {
+ tooltipEl.style.visibility = "hidden";
+ tooltipEl.style.opacity = 0;
+ }, 3000);
+ });
+ }
+
/**
* Handle the details dropdown component.
*
@@ -802,4 +826,24 @@ let BookWyrm = new (class {
form.querySelector('input[name="preferred_timezone"]').value = tz;
}
+
+ togglePasswordVisibility(event) {
+ const iconElement = event.currentTarget.getElementsByTagName("button")[0];
+ const passwordElementId = event.currentTarget.dataset.for;
+ const passwordInputElement = document.getElementById(passwordElementId);
+
+ if (!passwordInputElement) return;
+
+ if (passwordInputElement.type === "password") {
+ passwordInputElement.type = "text";
+ this.addRemoveClass(iconElement, "icon-eye-blocked");
+ this.addRemoveClass(iconElement, "icon-eye", true);
+ } else {
+ passwordInputElement.type = "password";
+ this.addRemoveClass(iconElement, "icon-eye");
+ this.addRemoveClass(iconElement, "icon-eye-blocked", true);
+ }
+
+ this.toggleFocus(passwordElementId);
+ }
})();
diff --git a/bookwyrm/static/js/forms.js b/bookwyrm/static/js/forms.js
index 08066f137..4a075506e 100644
--- a/bookwyrm/static/js/forms.js
+++ b/bookwyrm/static/js/forms.js
@@ -47,12 +47,11 @@
.querySelectorAll("[data-remove]")
.forEach((node) => node.addEventListener("click", removeInput));
- // Get the element, add a keypress listener...
+ // Get element, add a keypress listener...
document.getElementById("subjects").addEventListener("keypress", function (e) {
- // e.target is the element where it listens!
- // if e.target is input field within the "subjects" div, do stuff
+ // Linstening to element e.target
+ // If e.target is an input field within "subjects" div preventDefault()
if (e.target && e.target.nodeName == "INPUT") {
- // Item found, prevent default
if (event.keyCode == 13) {
event.preventDefault();
}
diff --git a/bookwyrm/storage_backends.py b/bookwyrm/storage_backends.py
deleted file mode 100644
index 6dd9f522c..000000000
--- a/bookwyrm/storage_backends.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""Handles backends for storages"""
-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
- """Storage class for Static contents"""
-
- location = "static"
- default_acl = "public-read"
-
-
-class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method
- """Storage class for Image files"""
-
- location = "images"
- default_acl = "public-read"
- file_overwrite = False
-
- """
- This is our custom version of S3Boto3Storage that fixes a bug in
- boto3 where the passed in file is closed upon upload.
- From:
- https://github.com/matthewwithanm/django-imagekit/issues/391#issuecomment-275367006
- https://github.com/boto/boto3/issues/929
- https://github.com/matthewwithanm/django-imagekit/issues/391
- """
-
- def _save(self, name, content):
- """
- We create a clone of the content file as when this is passed to
- boto3 it wrongly closes the file upon upload where as the storage
- backend expects it to still be open
- """
- # Seek our content back to the start
- content.seek(0, os.SEEK_SET)
-
- # Create a temporary file that will write to disk after a specified
- # size. This file will be automatically deleted when closed by
- # boto3 or after exiting the `with` statement if the boto3 is fixed
- with SpooledTemporaryFile() as content_autoclose:
-
- # Write our original content into our copy that will be closed by boto3
- content_autoclose.write(content.read())
-
- # 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
diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py
index d897feff7..3e1cf17bd 100644
--- a/bookwyrm/suggested_users.py
+++ b/bookwyrm/suggested_users.py
@@ -8,6 +8,7 @@ from opentelemetry import trace
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
+from bookwyrm.settings import INSTANCE_ACTOR_USERNAME
from bookwyrm.tasks import app, SUGGESTED_USERS
from bookwyrm.telemetry import open_telemetry
@@ -33,7 +34,6 @@ class SuggestedUsers(RedisStore):
def get_counts_from_rank(self, rank): # pylint: disable=no-self-use
"""calculate mutuals count and shared books count from rank"""
- # pylint: disable=c-extension-no-member
return {
"mutuals": math.floor(rank),
# "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1,
@@ -98,9 +98,15 @@ class SuggestedUsers(RedisStore):
for (pk, score) in values
]
# annotate users with mutuals and shared book counts
- users = models.User.objects.filter(
- is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values]
- ).annotate(mutuals=Case(*annotations, output_field=IntegerField(), default=0))
+ users = (
+ models.User.objects.filter(
+ is_active=True, bookwyrm_user=True, id__in=[pk for (pk, _) in values]
+ )
+ .annotate(
+ mutuals=Case(*annotations, output_field=IntegerField(), default=0)
+ )
+ .exclude(localname=INSTANCE_ACTOR_USERNAME)
+ )
if local:
users = users.filter(local=True)
return users.order_by("-mutuals")[:5]
@@ -121,7 +127,6 @@ def get_annotated_users(viewer, *args, **kwargs):
),
distinct=True,
),
- # pylint: disable=line-too-long
# shared_books=Count(
# "shelfbook",
# filter=Q(
@@ -195,7 +200,7 @@ def update_suggestions_on_unfollow(sender, instance, **kwargs):
@receiver(signals.post_save, sender=models.User)
-# pylint: disable=unused-argument, too-many-arguments
+# pylint: disable=unused-argument
def update_user(sender, instance, created, update_fields=None, **kwargs):
"""an updated user, neat"""
# a new user is found, create suggestions for them
@@ -254,7 +259,8 @@ def rerank_suggestions_task(user_id):
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)
+ if user:
+ suggested_users.rerank_obj(user, update_only=update_only)
@app.task(queue=SUGGESTED_USERS)
diff --git a/bookwyrm/templates/403.html b/bookwyrm/templates/403.html
new file mode 100644
index 000000000..0b78bc6b8
--- /dev/null
+++ b/bookwyrm/templates/403.html
@@ -0,0 +1,20 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load utilities %}
+
+{% block title %}{% trans "Oh no!" %}{% endblock %}
+
+{% block content %}
+
+
{% trans "Permission Denied" %}
+
+ {% blocktrans trimmed with level=request.user|get_user_permission %}
+ You do not have permission to view this page or perform this action. Your user permission level is {{ level }}
.
+ {% endblocktrans %}
+
+
{% trans "If you think you should have access, please speak to your BookWyrm server administrator." %}
+
+
+
+{% endblock %}
+
diff --git a/bookwyrm/templates/413.html b/bookwyrm/templates/413.html
new file mode 100644
index 000000000..a849a764f
--- /dev/null
+++ b/bookwyrm/templates/413.html
@@ -0,0 +1,16 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+
+{% block title %}{% trans "File too large" %}{% endblock %}
+
+{% block content %}
+
+
{% trans "File too large" %}
+
{% trans "The file you are uploading is too large." %}
+
+ {% blocktrans trimmed %}
+ You you can try using a smaller file, or ask your BookWyrm server administrator to increase the DATA_UPLOAD_MAX_MEMORY_SIZE
setting.
+ {% endblocktrans %}
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/about/about.html b/bookwyrm/templates/about/about.html
index 6705793d5..ef3f34037 100644
--- a/bookwyrm/templates/about/about.html
+++ b/bookwyrm/templates/about/about.html
@@ -31,10 +31,10 @@
-