Merge branch 'main' into 253-user-post-privacy-v2

This commit is contained in:
Mouse Reeve 2021-06-14 16:47:57 -07:00 committed by GitHub
commit 27a3d0ae96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
555 changed files with 49280 additions and 14114 deletions

View file

@ -1,2 +1,2 @@
[run] [run]
omit = */test*,celerywyrm*,bookwyrm/migrations/* omit = */test*,celerywyrm*,bookwyrm/migrations/*

View file

@ -4,4 +4,4 @@ __pycache__
*.pyd *.pyd
.git .git
.github .github
.pytest* .pytest*

39
.editorconfig Normal file
View file

@ -0,0 +1,39 @@
# @see https://editorconfig.org/
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
max_line_length = 100
# C-style doc comments
block_comment_start = /*
block_comment = *
block_comment_end = */
[{bw-dev,fr-dev,LICENSE}]
max_line_length = off
[*.{csv,json,html,md,po,py,svg,tsv}]
max_line_length = off
# ` ` at the end of a line is a line-break in markdown
[*.{md,markdown}]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
max_line_length = off
# Computer generated files
[{package.json,*.lock,*.mo}]
indent_size = unset
indent_style = unset
max_line_length = unset
insert_final_newline = unset

View file

@ -5,28 +5,39 @@ SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr"
DEBUG=true DEBUG=true
DOMAIN=your.domain.here DOMAIN=your.domain.here
#EMAIL=your@email.here
# Used for deciding which editions to prefer
DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts ## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" # ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
OL_URL=https://openlibrary.org
## Database backend to use.
## Default is postgres, sqlite is for dev quickstart only (NOT production!!!)
BOOKWYRM_DATABASE_BACKEND=postgres
MEDIA_ROOT=images/ MEDIA_ROOT=images/
POSTGRES_PASSWORD=fedireads POSTGRES_PORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads POSTGRES_USER=fedireads
POSTGRES_DB=fedireads POSTGRES_DB=fedireads
POSTGRES_HOST=db POSTGRES_HOST=db
CELERY_BROKER=redis://redis:6379/0 # Redis activity stream manager
CELERY_RESULT_BACKEND=redis://redis:6379/0 MAX_STREAM_LENGTH=200
REDIS_ACTIVITY_HOST=redis_activity
REDIS_ACTIVITY_PORT=6379
#REDIS_ACTIVITY_PASSWORD=redispassword345
# Redis as celery broker
REDIS_BROKER_PORT=6379
#REDIS_BROKER_PASSWORD=redispassword123
FLOWER_PORT=8888
#FLOWER_USER=mouse
#FLOWER_PASSWORD=changeme
EMAIL_HOST="smtp.mailgun.org" EMAIL_HOST="smtp.mailgun.org"
EMAIL_PORT=587 EMAIL_PORT=587
EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_USER=mail@your.domain.here
EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true EMAIL_USE_TLS=true
EMAIL_USE_SSL=false

43
.env.prod.example Normal file
View file

@ -0,0 +1,43 @@
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG=false
DOMAIN=your.domain.here
EMAIL=your@email.here
# Used for deciding which editions to prefer
DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
MEDIA_ROOT=images/
POSTGRES_PORT=5432
POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
POSTGRES_HOST=db
# Redis activity stream manager
MAX_STREAM_LENGTH=200
REDIS_ACTIVITY_HOST=redis_activity
REDIS_ACTIVITY_PORT=6379
REDIS_ACTIVITY_PASSWORD=redispassword345
# Redis as celery broker
REDIS_BROKER_PORT=6379
REDIS_BROKER_PASSWORD=redispassword123
FLOWER_PORT=8888
FLOWER_USER=mouse
FLOWER_PASSWORD=changeme
EMAIL_HOST="smtp.mailgun.org"
EMAIL_PORT=587
EMAIL_HOST_USER=mail@your.domain.here
EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
**/vendor/**

90
.eslintrc.js Normal file
View file

@ -0,0 +1,90 @@
/* global module */
module.exports = {
"env": {
"browser": true,
"es6": true
},
"extends": "eslint:recommended",
"rules": {
// Possible Errors
"no-async-promise-executor": "error",
"no-await-in-loop": "error",
"no-class-assign": "error",
"no-confusing-arrow": "error",
"no-const-assign": "error",
"no-dupe-class-members": "error",
"no-duplicate-imports": "error",
"no-template-curly-in-string": "error",
"no-useless-computed-key": "error",
"no-useless-constructor": "error",
"no-useless-rename": "error",
"require-atomic-updates": "error",
// Best practices
"strict": "error",
"no-var": "error",
// Stylistic Issues
"arrow-spacing": "error",
"capitalized-comments": [
"warn",
"always",
{
"ignoreConsecutiveComments": true
},
],
"keyword-spacing": "error",
"lines-around-comment": [
"error",
{
"beforeBlockComment": true,
"beforeLineComment": true,
"allowBlockStart": true,
"allowClassStart": true,
"allowObjectStart": true,
"allowArrayStart": true,
},
],
"no-multiple-empty-lines": [
"error",
{
"max": 1,
},
],
"padded-blocks": [
"error",
"never",
],
"padding-line-between-statements": [
"error",
{
// always before return
"blankLine": "always",
"prev": "*",
"next": "return",
},
{
// always before block-like expressions
"blankLine": "always",
"prev": "*",
"next": "block-like",
},
{
// always after variable declaration
"blankLine": "always",
"prev": [ "const", "let", "var" ],
"next": "*",
},
{
// not necessary between variable declaration
"blankLine": "any",
"prev": [ "const", "let", "var" ],
"next": [ "const", "let", "var" ],
},
],
"space-before-blocks": "error",
}
};

View file

@ -24,15 +24,15 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):** **Desktop (please complete the following information):**
- OS: [e.g. iOS] - OS: [e.g. iOS]
- Browser [e.g. chrome, safari] - Browser [e.g. chrome, safari]
- Version [e.g. 22] - Version [e.g. 22]
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6] - Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1] - OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari] - Browser [e.g. stock browser, safari]
- Version [e.g. 22] - Version [e.g. 22]
**Additional context** **Additional context**
Add any other context about the problem here. Add any other context about the problem here.

11
.github/workflows/black.yml vendored Normal file
View file

@ -0,0 +1,11 @@
name: Lint Python
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: psf/black@21.4b2

View file

@ -50,7 +50,6 @@ jobs:
SECRET_KEY: beepbeep SECRET_KEY: beepbeep
DEBUG: true DEBUG: true
DOMAIN: your.domain.here DOMAIN: your.domain.here
OL_URL: https://openlibrary.org
BOOKWYRM_DATABASE_BACKEND: postgres BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/ MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2 POSTGRES_PASSWORD: hunter2
@ -58,11 +57,12 @@ jobs:
POSTGRES_DB: github_actions POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1 POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: "" CELERY_BROKER: ""
CELERY_RESULT_BACKEND: "" REDIS_BROKER_PORT: 6379
FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org" EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587 EMAIL_PORT: 587
EMAIL_HOST_USER: "" EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: "" EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true EMAIL_USE_TLS: true
run: | run: |
python manage.py test -v 3 python manage.py test

38
.github/workflows/lint-frontend.yaml vendored Normal file
View file

@ -0,0 +1,38 @@
# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
name: Lint Frontend
on:
push:
branches: [ main, ci, frontend ]
paths:
- '.github/workflows/**'
- 'static/**'
- '.eslintrc'
- '.stylelintrc'
pull_request:
branches: [ main, ci, frontend ]
jobs:
lint:
name: Lint with stylelint and ESLint.
runs-on: ubuntu-20.04
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
- uses: actions/checkout@v2
- name: Install modules
run: yarn
# See .stylelintignore for files that are not linted.
- name: Run stylelint
run: >
yarn stylelint bookwyrm/static/**/*.css \
--report-needless-disables \
--report-invalid-scope-disables
# See .eslintignore for files that are not linted.
- name: Run ESLint
run: >
yarn eslint bookwyrm/static \
--ext .js,.jsx,.ts,.tsx

21
.github/workflows/lint-global.yaml vendored Normal file
View file

@ -0,0 +1,21 @@
# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
name: Lint project globally
on:
push:
branches: [ main, ci ]
pull_request:
branches: [ main, ci ]
jobs:
lint:
name: Lint with EditorConfig.
runs-on: ubuntu-20.04
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: EditorConfig
uses: greut/eclint-action@v0

13
.gitignore vendored
View file

@ -2,6 +2,8 @@
/venv /venv
*.pyc *.pyc
*.swp *.swp
**/__pycache__
.local
# VSCode # VSCode
/.vscode /.vscode
@ -15,4 +17,13 @@
/images/ /images/
# Testing # Testing
.coverage .coverage
#PyCharm
.idea
#Node tools
/node_modules/
#nginx
nginx/default.conf

1
.stylelintignore Normal file
View file

@ -0,0 +1 @@
**/vendor/**

17
.stylelintrc.js Normal file
View file

@ -0,0 +1,17 @@
/* global module */
module.exports = {
"extends": "stylelint-config-standard",
"plugins": [
"stylelint-order"
],
"rules": {
"order/order": [
"custom-properties",
"declarations"
],
"indentation": 4
}
};

View file

@ -23,13 +23,13 @@ include:
Examples of unacceptable behavior by participants include: Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or * The use of sexualized language or imagery and unwelcome sexual attention or
advances advances
* Trolling, insulting/derogatory comments, and personal or political attacks * Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment * Public or private harassment
* Publishing others' private information, such as a physical or electronic * Publishing others' private information, such as a physical or electronic
address, without explicit permission address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a * Other conduct which could reasonably be considered inappropriate in a
professional setting professional setting
## Our Responsibilities ## Our Responsibilities

View file

@ -2,14 +2,10 @@ FROM python:3.9
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
RUN mkdir /app RUN mkdir /app /app/static /app/images
RUN mkdir /app/static
RUN mkdir /app/images
WORKDIR /app WORKDIR /app
COPY requirements.txt /app/ COPY requirements.txt /app/
RUN pip install -r requirements.txt RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean
COPY ./bookwyrm /app
COPY ./celerywyrm /app

View file

@ -9,10 +9,11 @@ Permission is hereby granted, free of charge, to any person or organization (the
1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. 1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software.
2. The User is one of the following: 2. The User is one of the following:
a. An individual person, laboring for themselves
b. A non-profit organization 1. An individual person, laboring for themselves
c. An educational institution 2. A non-profit organization
d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor 3. An educational institution
4. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor
3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. 3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote.

150
README.md
View file

@ -6,36 +6,24 @@ Social reading and reviewing, decentralized with ActivityPub
- [Joining BookWyrm](#joining-bookwyrm) - [Joining BookWyrm](#joining-bookwyrm)
- [Contributing](#contributing) - [Contributing](#contributing)
- [About BookWyrm](#about-bookwyrm) - [About BookWyrm](#about-bookwyrm)
- [What it is and isn't](#what-it-is-and-isnt) - [What it is and isn't](#what-it-is-and-isnt)
- [The role of federation](#the-role-of-federation) - [The role of federation](#the-role-of-federation)
- [Features](#features) - [Features](#features)
- [Setting up the developer environment](#setting-up-the-developer-environment) - [Book data](#book-data)
- [Installing in Production](#installing-in-production) - [Set up Bookwyrm](#set-up-bookwyrm)
- [Book data](#book-data)
## Joining BookWyrm ## Joining BookWyrm
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list. BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://docs.joinbookwyrm.com/instances.html) list.
You can request an invite to https://bookwyrm.social by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice). You can request an invite by entering your email address at https://bookwyrm.social.
## Contributing ## Contributing
There are many ways you can contribute to this project, regardless of your level of technical expertise. See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
### Feedback and feature requests
Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues).
### Code contributions
Code contributons are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out.
If you have questions about the project or contributing, you can seet up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
### Financial Support
BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo).
## About BookWyrm ## About BookWyrm
### What it is and isn't ### What it is and isn't
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree. BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
### The role of federation ### The role of federation
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance. BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
@ -43,125 +31,55 @@ BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub,
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks. Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
### Features ### Features
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going! Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/bookwyrm-social/bookwyrm/issues) to get the conversation going!
- Posting about books - Posting about books
- Compose reviews, with or without ratings, which are aggregated in the book page - Compose reviews, with or without ratings, which are aggregated in the book page
- Compose other kinds of statuses about books, such as: - Compose other kinds of statuses about books, such as:
- Comments on a book - Comments on a book
- Quotes or excerpts - Quotes or excerpts
- Reply to statuses - Reply to statuses
- View aggregate reviews of a book across connected BookWyrm instances - View aggregate reviews of a book across connected BookWyrm instances
- Differentiate local and federated reviews and rating in your activity feed - Differentiate local and federated reviews and rating in your activity feed
- Track reading activity - Track reading activity
- Shelve books on default "to-read," "currently reading," and "read" shelves - Shelve books on default "to-read," "currently reading," and "read" shelves
- Create custom shelves - Create custom shelves
- Store started reading/finished reading dates, as well as progress updates along the way - Store started reading/finished reading dates, as well as progress updates along the way
- Update followers about reading activity (optionally, and with granular privacy controls) - Update followers about reading activity (optionally, and with granular privacy controls)
- Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator - Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
- Federation with ActivityPub - Federation with ActivityPub
- Broadcast and receive user statuses and activity - Broadcast and receive user statuses and activity
- Share book data between instances to create a networked database of metadata - Share book data between instances to create a networked database of metadata
- Identify shared books across instances and aggregate related content - Identify shared books across instances and aggregate related content
- Follow and interact with users across BookWyrm instances - Follow and interact with users across BookWyrm instances
- Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported) - Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
- Granular privacy controls - Granular privacy controls
- Private, followers-only, and public privacy levels for posting, shelves, and lists - Private, followers-only, and public privacy levels for posting, shelves, and lists
- Option for users to manually approve followers - Option for users to manually approve followers
- Allow blocking and flagging for moderation - Allow blocking and flagging for moderation
### The Tech Stack ### The Tech Stack
Web backend Web backend
- [Django](https://www.djangoproject.com/) web server - [Django](https://www.djangoproject.com/) web server
- [PostgreSQL](https://www.postgresql.org/) database - [PostgreSQL](https://www.postgresql.org/) database
- [ActivityPub](http://activitypub.rocks/) federation - [ActivityPub](https://activitypub.rocks/) federation
- [Celery](http://celeryproject.org/) task queuing - [Celery](https://docs.celeryproject.org/) task queuing
- [Redis](https://redis.io/) task backend - [Redis](https://redis.io/) task backend
- [Redis (again)](https://redis.io/) activity stream manager
Front end Front end
- Django templates - Django templates
- [Bulma.io](https://bulma.io/) css framework - [Bulma.io](https://bulma.io/) css framework
- Vanilla JavaScript, in moderation - Vanilla JavaScript, in moderation
Deployment Deployment
- [Docker](https://www.docker.com/) and docker-compose - [Docker](https://www.docker.com/) and docker-compose
- [Gunicorn](https://gunicorn.org/) web runner - [Gunicorn](https://gunicorn.org/) web runner
- [Flower](https://github.com/mher/flower) celery monitoring - [Flower](https://github.com/mher/flower) celery monitoring
- [Nginx](https://nginx.org/en/) HTTP server - [Nginx](https://nginx.org/en/) HTTP server
## Setting up the developer environment
Set up the environment file:
``` bash
cp .env.example .env
```
For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain.
You'll have to install the Docker and docker-compose. When you're ready, run:
```bash
docker-compose build
docker-compose run --rm web python manage.py migrate
docker-compose run --rm web python manage.py initdb
docker-compose up
```
Once the build is complete, you can access the instance at `localhost:1333`
## Installing in Production
This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production.
### Server setup
- Get a domain name and set up DNS for your server
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04)
- Set up a mailgun account and the appropriate DNS settings
- Install Docker and docker-compose
### Install and configure BookWyrm
- Get the application code:
`git clone git@github.com:mouse-reeve/bookwyrm.git`
- Switch to the `production` branch
`git checkout production`
- Create your environment variables file
`cp .env.example .env`
- Add your domain, email address, mailgun credentials
- Set a secure redis password and secret key
- Set a secure database password for postgres
- Update your nginx configuration in `nginx/default.conf`
- Replace `your-domain.com` with your domain name
- Run the application (this should also set up a Certbot ssl cert for your domain)
`docker-compose up --build`
Make sure all the images build successfully
- When docker has built successfully, stop the process with `CTRL-C`
- Comment out the `command: certonly...` line in `docker-compose.yml`
- Run docker-compose in the background
`docker-compose up -d`
- Initialize the database
`./bw-dev initdb`
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe locationgi
- Congrats! You did it, go to your domain and enjoy the fruits of your labors
### Configure your instance
- Register a user account in the applcation UI
- Make your account a superuser (warning: do *not* use django's `createsuperuser` command)
- On your server, open the django shell
`./bw-dev shell`
- Load your user and make it a superuser
```python
from bookwyrm import models
user = models.User.objects.get(id=1)
user.is_staff = True
user.is_superuser = True
user.save()
```
- Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
## Book data ## Book data
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written. The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
There are three concepts in the book data model: ## Set up Bookwyrm
- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition` The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html).
- `Work`, the theoretical umbrella concept of a book that encompasses every edition of the book, and
- `Edition`, a concrete, actually published version of a book
Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page.

View file

@ -1,25 +1,31 @@
''' bring activitypub functions into the namespace ''' """ bring activitypub functions into the namespace """
import inspect import inspect
import sys import sys
from .base_activity import ActivityEncoder, Signature from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Image from .image import Document, Image
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation from .note import Note, GeneratedNote, Article, Comment, Quotation
from .note import Review, Rating
from .note import Tombstone from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .ordered_collection import CollectionItem, ListItem, ShelfItem
from .ordered_collection import BookList, Shelf from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey from .person import Person, PublicKey
from .response import ActivitypubResponse from .response import ActivitypubResponse
from .book import Edition, Work, Author from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block from .verbs import Follow, Accept, Reject, Block
from .verbs import Add, AddBook, AddListItem, Remove from .verbs import Add, Remove
from .verbs import Announce, Like
# this creates a list of all the Activity types that we can serialize, # 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 # so when an Activity comes in from outside, we can check if it's known
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_objects = {c[0]: c[1] for c in cls_members \ activity_objects = {c[0]: c[1] for c in cls_members if hasattr(c[1], "to_model")}
if hasattr(c[1], 'to_model')}
def parse(activity_json):
"""figure out what activity this is and parse it"""
return naive_parse(activity_objects, activity_json)

View file

@ -1,4 +1,4 @@
''' basics for an activitypub serializer ''' """ basics for an activitypub serializer """
from dataclasses import dataclass, fields, MISSING from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder from json import JSONEncoder
@ -8,86 +8,128 @@ from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app from bookwyrm.tasks import app
class ActivitySerializerError(ValueError): class ActivitySerializerError(ValueError):
''' routine problems serializing activitypub json ''' """routine problems serializing activitypub json"""
class ActivityEncoder(JSONEncoder): class ActivityEncoder(JSONEncoder):
''' used to convert an Activity object into json ''' """used to convert an Activity object into json"""
def default(self, o): def default(self, o):
return o.__dict__ return o.__dict__
@dataclass @dataclass
class Link: class Link:
''' for tagging a book in a status ''' """for tagging a book in a status"""
href: str href: str
name: str name: str
type: str = 'Link' type: str = "Link"
@dataclass @dataclass
class Mention(Link): class Mention(Link):
''' a subtype of Link for mentioning an actor ''' """a subtype of Link for mentioning an actor"""
type: str = 'Mention'
type: str = "Mention"
@dataclass @dataclass
class Signature: class Signature:
''' public key block ''' """public key block"""
creator: str creator: str
created: str created: str
signatureValue: str signatureValue: str
type: str = 'RsaSignature2017' type: str = "RsaSignature2017"
def naive_parse(activity_objects, activity_json, serializer=None):
"""this navigates circular import issues"""
if not serializer:
if activity_json.get("publicKeyPem"):
# ugh
activity_json["type"] = "PublicKey"
activity_type = activity_json.get("type")
try:
serializer = activity_objects[activity_type]
except KeyError as e:
# we know this exists and that we can't handle it
if activity_type in ["Question"]:
return None
raise ActivitySerializerError(e)
return serializer(activity_objects=activity_objects, **activity_json)
@dataclass(init=False) @dataclass(init=False)
class ActivityObject: class ActivityObject:
''' actor activitypub json ''' """actor activitypub json"""
id: str id: str
type: str type: str
def __init__(self, **kwargs): def __init__(self, activity_objects=None, **kwargs):
''' this lets you pass in an object with fields that aren't in the """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 dataclass, which it ignores. Any field in the dataclass is required or
has a default value ''' has a default value"""
for field in fields(self): for field in fields(self):
try: try:
value = kwargs[field.name] value = kwargs[field.name]
if value in (None, MISSING, {}):
raise KeyError()
try:
is_subclass = issubclass(field.type, ActivityObject)
except TypeError:
is_subclass = False
# serialize a model obj
if hasattr(value, "to_activity"):
value = value.to_activity()
# parse a dict into the appropriate activity
elif is_subclass and isinstance(value, dict):
if activity_objects:
value = naive_parse(activity_objects, value)
else:
value = naive_parse(
activity_objects, value, serializer=field.type
)
except KeyError: except KeyError:
if field.default == MISSING and \ if field.default == MISSING and field.default_factory == MISSING:
field.default_factory == MISSING: raise ActivitySerializerError(
raise ActivitySerializerError(\ "Missing required field: %s" % field.name
'Missing required field: %s' % field.name) )
value = field.default value = field.default
setattr(self, field.name, value) setattr(self, field.name, value)
def to_model(self, model=None, instance=None, allow_create=True, save=True):
"""convert from an activity to a model instance"""
model = model or get_model_from_type(self.type)
def to_model(self, model, instance=None, save=True): # only reject statuses if we're potentially creating them
''' convert from an activity to a model instance ''' if (
if self.type != model.activity_serializer.type: allow_create
raise ActivitySerializerError( and hasattr(model, "ignore_activity")
'Wrong activity type "%s" for activity of type "%s"' % \ and model.ignore_activity(self)
(model.activity_serializer.type, ):
self.type) return None
)
if not isinstance(self, model.activity_serializer): # check for an existing instance
raise ActivitySerializerError( instance = instance or model.find_existing(self.serialize())
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
(self.__class__,
model.__name__,
model.activity_serializer)
)
if hasattr(model, 'ignore_activity') and model.ignore_activity(self): if not instance and not allow_create:
return instance # so that we don't create when we want to delete or update
return None
# check for an existing instance, if we're not updating a known obj instance = instance or model()
instance = instance or model.find_existing(self.serialize()) or model()
for field in instance.simple_fields: for field in instance.simple_fields:
field.set_field_from_activity(instance, self) try:
field.set_field_from_activity(instance, self)
except AttributeError as e:
raise ActivitySerializerError(e)
# image fields have to be set after other fields because they can save # image fields have to be set after other fields because they can save
# too early and jank up users # too early and jank up users
@ -113,8 +155,10 @@ class ActivityObject:
field.set_field_from_activity(instance, self) field.set_field_from_activity(instance, self)
# reversed relationships in the models # reversed relationships in the models
for (model_field_name, activity_field_name) in \ for (
instance.deserialize_reverse_fields: model_field_name,
activity_field_name,
) in instance.deserialize_reverse_fields:
# attachments on Status, for example # attachments on Status, for example
values = getattr(self, activity_field_name) values = getattr(self, activity_field_name)
if values is None or values is MISSING: if values is None or values is MISSING:
@ -132,30 +176,33 @@ class ActivityObject:
instance.__class__.__name__, instance.__class__.__name__,
related_field_name, related_field_name,
instance.remote_id, instance.remote_id,
item item,
) )
return instance return instance
def serialize(self): def serialize(self):
''' convert to dictionary with context attr ''' """convert to dictionary with context attr"""
data = self.__dict__ data = self.__dict__.copy()
data = {k:v for (k, v) in data.items() if v is not None} # recursively serialize
data['@context'] = 'https://www.w3.org/ns/activitystreams' for (k, v) in data.items():
try:
if issubclass(type(v), ActivityObject):
data[k] = v.serialize()
except TypeError:
pass
data = {k: v for (k, v) in data.items() if v is not None}
data["@context"] = "https://www.w3.org/ns/activitystreams"
return data return data
@app.task @app.task
@transaction.atomic @transaction.atomic
def set_related_field( def set_related_field(
model_name, origin_model_name, related_field_name, model_name, origin_model_name, related_field_name, related_remote_id, data
related_remote_id, data): ):
''' load reverse related fields (editions, attachments) without blocking ''' """load reverse related fields (editions, attachments) without blocking"""
model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True) model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
origin_model = apps.get_model( origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
'bookwyrm.%s' % origin_model_name,
require_ready=True
)
with transaction.atomic(): with transaction.atomic():
if isinstance(data, str): if isinstance(data, str):
@ -169,48 +216,71 @@ def set_related_field(
# this must exist because it's the object that triggered this function # this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id) instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance: if not instance:
raise ValueError( raise ValueError("Invalid related remote id: %s" % related_remote_id)
'Invalid related remote id: %s' % related_remote_id)
# set the origin's remote id on the activity so it will be there when # set the origin's remote id on the activity so it will be there when
# the model instance is created # the model instance is created
# edition.parentWork = instance, for example # edition.parentWork = instance, for example
model_field = getattr(model, related_field_name) model_field = getattr(model, related_field_name)
if hasattr(model_field, 'activitypub_field'): if hasattr(model_field, "activitypub_field"):
setattr( setattr(
activity, activity, getattr(model_field, "activitypub_field"), instance.remote_id
getattr(model_field, 'activitypub_field'),
instance.remote_id
) )
item = activity.to_model(model) item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then # if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation # we have to set it post-creation
if not hasattr(model_field, 'activitypub_field'): if not hasattr(model_field, "activitypub_field"):
setattr(item, related_field_name, instance) setattr(item, related_field_name, instance)
item.save() item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True): def get_model_from_type(activity_type):
''' take a remote_id and return an instance, creating if necessary ''' """given the activity, what type of model"""
result = model.find_existing_by_remote_id(remote_id) models = apps.get_models()
if result and not refresh: model = [
return result m
for m in models
if hasattr(m, "activity_serializer")
and hasattr(m.activity_serializer, "type")
and m.activity_serializer.type == activity_type
]
if not model:
raise ActivitySerializerError(
'No model found for activity type "%s"' % activity_type
)
return model[0]
def resolve_remote_id(
remote_id, model=None, refresh=False, save=True, get_activity=False
):
"""take a remote_id and return an instance, creating if necessary"""
if model: # a bonus check we can do if we already know the model
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result if not get_activity else result.to_activity_dataclass()
# load the data and create the object # load the data and create the object
try: try:
data = get_data(remote_id) data = get_data(remote_id)
except (ConnectorException, ConnectionError): except ConnectorException:
raise ActivitySerializerError( raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \ "Could not connect to host for remote_id in: %s" % (remote_id)
(model.__name__, remote_id)) )
# determine the model implicitly, if not provided
# or if it's a model with subclasses like Status, check again
if not model or hasattr(model.objects, "select_subclasses"):
model = get_model_from_type(data.get("type"))
# check for existing items with shared unique identifiers # check for existing items with shared unique identifiers
if not result: result = model.find_existing(data)
result = model.find_existing(data) if result and not refresh:
if result and not refresh: return result if not get_activity else result.to_activity_dataclass()
return result
item = model.activity_serializer(**data) item = model.activity_serializer(**data)
if get_activity:
return item
# if we're refreshing, "result" will be set and we'll update it # if we're refreshing, "result" will be set and we'll update it
return item.to_model(model, instance=result, save=save) return item.to_model(model=model, instance=result, save=save)

View file

@ -1,70 +1,82 @@
''' book and author data ''' """ book and author data """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List from typing import List
from .base_activity import ActivityObject from .base_activity import ActivityObject
from .image import Image from .image import Document
@dataclass(init=False) @dataclass(init=False)
class Book(ActivityObject): class BookData(ActivityObject):
''' serializes an edition or work, abstract ''' """shared fields for all book data and authors"""
openlibraryKey: str = None
inventaireId: str = None
librarythingKey: str = None
goodreadsKey: str = None
bnfId: str = None
lastEditedBy: str = None
@dataclass(init=False)
class Book(BookData):
"""serializes an edition or work, abstract"""
title: str title: str
sortTitle: str = '' sortTitle: str = ""
subtitle: str = '' subtitle: str = ""
description: str = '' description: str = ""
languages: List[str] = field(default_factory=lambda: []) languages: List[str] = field(default_factory=lambda: [])
series: str = '' series: str = ""
seriesNumber: str = '' seriesNumber: str = ""
subjects: List[str] = field(default_factory=lambda: []) subjects: List[str] = field(default_factory=lambda: [])
subjectPlaces: List[str] = field(default_factory=lambda: []) subjectPlaces: List[str] = field(default_factory=lambda: [])
authors: List[str] = field(default_factory=lambda: []) authors: List[str] = field(default_factory=lambda: [])
firstPublishedDate: str = '' firstPublishedDate: str = ""
publishedDate: str = '' publishedDate: str = ""
openlibraryKey: str = '' cover: Document = None
librarythingKey: str = '' type: str = "Book"
goodreadsKey: str = ''
cover: Image = field(default_factory=lambda: {})
type: str = 'Book'
@dataclass(init=False) @dataclass(init=False)
class Edition(Book): class Edition(Book):
''' Edition instance of a book object ''' """Edition instance of a book object"""
work: str work: str
isbn10: str = '' isbn10: str = ""
isbn13: str = '' isbn13: str = ""
oclcNumber: str = '' oclcNumber: str = ""
asin: str = '' asin: str = ""
pages: int = None pages: int = None
physicalFormat: str = '' physicalFormat: str = ""
publishers: List[str] = field(default_factory=lambda: []) publishers: List[str] = field(default_factory=lambda: [])
editionRank: int = 0 editionRank: int = 0
type: str = 'Edition' type: str = "Edition"
@dataclass(init=False) @dataclass(init=False)
class Work(Book): class Work(Book):
''' work instance of a book object ''' """work instance of a book object"""
lccn: str = ''
defaultEdition: str = '' lccn: str = ""
editions: List[str] = field(default_factory=lambda: []) editions: List[str] = field(default_factory=lambda: [])
type: str = 'Work' type: str = "Work"
@dataclass(init=False) @dataclass(init=False)
class Author(ActivityObject): class Author(BookData):
''' author of a book ''' """author of a book"""
name: str name: str
isni: str = None
viafId: str = None
gutenbergId: str = None
born: str = None born: str = None
died: str = None died: str = None
aliases: List[str] = field(default_factory=lambda: []) aliases: List[str] = field(default_factory=lambda: [])
bio: str = '' bio: str = ""
openlibraryKey: str = '' wikipediaLink: str = ""
librarythingKey: str = '' type: str = "Author"
goodreadsKey: str = ''
wikipediaLink: str = ''
type: str = 'Person'

View file

@ -1,11 +1,20 @@
''' an image, nothing fancy ''' """ an image, nothing fancy """
from dataclasses import dataclass from dataclasses import dataclass
from .base_activity import ActivityObject from .base_activity import ActivityObject
@dataclass(init=False) @dataclass(init=False)
class Image(ActivityObject): class Document(ActivityObject):
''' image block ''' """a document"""
url: str url: str
name: str = '' name: str = ""
type: str = 'Image' type: str = "Document"
id: str = '' id: str = None
@dataclass(init=False)
class Image(Document):
"""an image"""
type: str = "Image"

View file

@ -1,20 +0,0 @@
''' boosting and liking posts '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Like(ActivityObject):
''' a user faving an object '''
actor: str
object: str
type: str = 'Like'
@dataclass(init=False)
class Boost(ActivityObject):
''' boosting a status '''
actor: str
object: str
type: str = 'Announce'

View file

@ -1,65 +1,87 @@
''' note serializer and children thereof ''' """ note serializer and children thereof """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict, List from typing import Dict, List
from django.apps import apps
from .base_activity import ActivityObject, Link from .base_activity import ActivityObject, Link
from .image import Image from .image import Document
@dataclass(init=False) @dataclass(init=False)
class Tombstone(ActivityObject): class Tombstone(ActivityObject):
''' the placeholder for a deleted status ''' """the placeholder for a deleted status"""
published: str
deleted: str type: str = "Tombstone"
type: str = 'Tombstone'
def to_model(self, *args, **kwargs): # pylint: disable=unused-argument
"""this should never really get serialized, just searched for"""
model = apps.get_model("bookwyrm.Status")
return model.find_existing_by_remote_id(self.id)
@dataclass(init=False) @dataclass(init=False)
class Note(ActivityObject): class Note(ActivityObject):
''' Note activity ''' """Note activity"""
published: str published: str
attributedTo: str attributedTo: str
content: str = '' content: str = ""
to: List[str] = field(default_factory=lambda: []) to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {}) replies: Dict = field(default_factory=lambda: {})
inReplyTo: str = '' inReplyTo: str = ""
summary: str = '' summary: str = ""
tag: List[Link] = field(default_factory=lambda: []) tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: []) attachment: List[Document] = field(default_factory=lambda: [])
sensitive: bool = False sensitive: bool = False
type: str = 'Note' type: str = "Note"
@dataclass(init=False) @dataclass(init=False)
class Article(Note): class Article(Note):
''' what's an article except a note with more fields ''' """what's an article except a note with more fields"""
name: str name: str
type: str = 'Article' type: str = "Article"
@dataclass(init=False) @dataclass(init=False)
class GeneratedNote(Note): class GeneratedNote(Note):
''' just a re-typed note ''' """just a re-typed note"""
type: str = 'GeneratedNote'
type: str = "GeneratedNote"
@dataclass(init=False) @dataclass(init=False)
class Comment(Note): class Comment(Note):
''' like a note but with a book ''' """like a note but with a book"""
inReplyToBook: str inReplyToBook: str
type: str = 'Comment' type: str = "Comment"
@dataclass(init=False)
class Review(Comment):
''' a full book review '''
name: str = None
rating: int = None
type: str = 'Review'
@dataclass(init=False) @dataclass(init=False)
class Quotation(Comment): class Quotation(Comment):
''' a quote and commentary on a book ''' """a quote and commentary on a book"""
quote: str quote: str
type: str = 'Quotation' type: str = "Quotation"
@dataclass(init=False)
class Review(Comment):
"""a full book review"""
name: str = None
rating: int = None
type: str = "Review"
@dataclass(init=False)
class Rating(Comment):
"""just a star rating"""
rating: int
content: str = None
name: str = None # not used, but the model inherits from Review
type: str = "Rating"

View file

@ -1,4 +1,4 @@
''' defines activitypub collections (lists) ''' """ defines activitypub collections (lists) """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import List from typing import List
@ -7,37 +7,73 @@ from .base_activity import ActivityObject
@dataclass(init=False) @dataclass(init=False)
class OrderedCollection(ActivityObject): class OrderedCollection(ActivityObject):
''' structure of an ordered collection activity ''' """structure of an ordered collection activity"""
totalItems: int totalItems: int
first: str first: str
last: str = None last: str = None
name: str = None name: str = None
owner: str = None owner: str = None
type: str = 'OrderedCollection' type: str = "OrderedCollection"
@dataclass(init=False) @dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection): class OrderedCollectionPrivate(OrderedCollection):
"""an ordered collection with privacy settings"""
to: List[str] = field(default_factory=lambda: []) to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: [])
@dataclass(init=False) @dataclass(init=False)
class Shelf(OrderedCollectionPrivate): class Shelf(OrderedCollectionPrivate):
''' structure of an ordered collection activity ''' """structure of an ordered collection activity"""
type: str = 'Shelf'
type: str = "Shelf"
@dataclass(init=False) @dataclass(init=False)
class BookList(OrderedCollectionPrivate): class BookList(OrderedCollectionPrivate):
''' structure of an ordered collection activity ''' """structure of an ordered collection activity"""
summary: str = None summary: str = None
curation: str = 'closed' curation: str = "closed"
type: str = 'BookList' type: str = "BookList"
@dataclass(init=False) @dataclass(init=False)
class OrderedCollectionPage(ActivityObject): class OrderedCollectionPage(ActivityObject):
''' structure of an ordered collection activity ''' """structure of an ordered collection activity"""
partOf: str partOf: str
orderedItems: List orderedItems: List
next: str next: str = None
prev: str prev: str = None
type: str = 'OrderedCollectionPage' type: str = "OrderedCollectionPage"
@dataclass(init=False)
class CollectionItem(ActivityObject):
"""an item in a collection"""
actor: str
type: str = "CollectionItem"
@dataclass(init=False)
class ListItem(CollectionItem):
"""a book on a list"""
book: str
notes: str = None
approved: bool = True
order: int = None
type: str = "ListItem"
@dataclass(init=False)
class ShelfItem(CollectionItem):
"""a book on a list"""
book: str
type: str = "ShelfItem"

View file

@ -1,4 +1,4 @@
''' actor serializer ''' """ actor serializer """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict from typing import Dict
@ -8,25 +8,28 @@ from .image import Image
@dataclass(init=False) @dataclass(init=False)
class PublicKey(ActivityObject): class PublicKey(ActivityObject):
''' public key block ''' """public key block"""
owner: str owner: str
publicKeyPem: str publicKeyPem: str
type: str = 'PublicKey' type: str = "PublicKey"
@dataclass(init=False) @dataclass(init=False)
class Person(ActivityObject): class Person(ActivityObject):
''' actor activitypub json ''' """actor activitypub json"""
preferredUsername: str preferredUsername: str
inbox: str inbox: str
outbox: str
followers: str
publicKey: PublicKey publicKey: PublicKey
endpoints: Dict followers: str = None
following: str = None
outbox: str = None
endpoints: Dict = None
name: str = None name: str = None
summary: str = None summary: str = None
icon: Image = field(default_factory=lambda: {}) icon: Image = field(default_factory=lambda: {})
bookwyrmUser: bool = False bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False manuallyApprovesFollowers: str = False
discoverable: str = True discoverable: str = False
type: str = 'Person' type: str = "Person"

View file

@ -2,6 +2,7 @@ from django.http import JsonResponse
from .base_activity import ActivityEncoder from .base_activity import ActivityEncoder
class ActivitypubResponse(JsonResponse): class ActivitypubResponse(JsonResponse):
""" """
A class to be used in any place that's serializing responses for A class to be used in any place that's serializing responses for
@ -9,10 +10,17 @@ class ActivitypubResponse(JsonResponse):
configures some stuff beforehand. Made to be a drop-in replacement of configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse. JsonResponse.
""" """
def __init__(self, data, encoder=ActivityEncoder, safe=True,
json_dumps_params=None, **kwargs):
if 'content_type' not in kwargs: def __init__(
kwargs['content_type'] = 'application/activity+json' self,
data,
encoder=ActivityEncoder,
safe=False,
json_dumps_params=None,
**kwargs
):
if "content_type" not in kwargs:
kwargs["content_type"] = "application/activity+json"
super().__init__(data, encoder, safe, json_dumps_params, **kwargs) super().__init__(data, encoder, safe, json_dumps_params, **kwargs)

View file

@ -1,97 +1,207 @@
''' undo wrapper activity ''' """ activities that do things """
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import List from typing import List
from django.apps import apps
from .base_activity import ActivityObject, Signature, resolve_remote_id
from .ordered_collection import CollectionItem
from .base_activity import ActivityObject, Signature
from .book import Edition
@dataclass(init=False) @dataclass(init=False)
class Verb(ActivityObject): class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of """generic fields for activities"""
abstraction but w/e '''
actor: str actor: str
object: ActivityObject object: ActivityObject
def action(self):
"""usually we just want to update and save"""
# self.object may return None if the object is invalid in an expected way
# ie, Question type
if self.object:
self.object.to_model()
@dataclass(init=False) @dataclass(init=False)
class Create(Verb): class Create(Verb):
''' Create activity ''' """Create activity"""
to: List
cc: List to: List[str]
cc: List[str] = field(default_factory=lambda: [])
signature: Signature = None signature: Signature = None
type: str = 'Create' type: str = "Create"
@dataclass(init=False) @dataclass(init=False)
class Delete(Verb): class Delete(Verb):
''' Create activity ''' """Create activity"""
to: List
cc: List to: List[str]
type: str = 'Delete' cc: List[str] = field(default_factory=lambda: [])
type: str = "Delete"
def action(self):
"""find and delete the activity object"""
if not self.object:
return
if isinstance(self.object, str):
# Deleted users are passed as strings. Not wild about this fix
model = apps.get_model("bookwyrm.User")
obj = model.find_existing_by_remote_id(self.object)
else:
obj = self.object.to_model(save=False, allow_create=False)
if obj:
obj.delete()
# if we can't find it, we don't need to delete it because we don't have it
@dataclass(init=False) @dataclass(init=False)
class Update(Verb): class Update(Verb):
''' Update activity ''' """Update activity"""
to: List
type: str = 'Update' to: List[str]
type: str = "Update"
def action(self):
"""update a model instance from the dataclass"""
if self.object:
self.object.to_model(allow_create=False)
@dataclass(init=False) @dataclass(init=False)
class Undo(Verb): class Undo(Verb):
''' Undo an activity ''' """Undo an activity"""
type: str = 'Undo'
type: str = "Undo"
def action(self):
"""find and remove the activity object"""
if isinstance(self.object, str):
# it may be that sometihng should be done with these, but idk what
# this seems just to be coming from pleroma
return
# this is so hacky but it does make it work....
# (because you Reject a request and Undo a follow
model = None
if self.object.type == "Follow":
model = apps.get_model("bookwyrm.UserFollows")
obj = self.object.to_model(model=model, save=False, allow_create=False)
if not obj:
# this could be a folloq request not a follow proper
model = apps.get_model("bookwyrm.UserFollowRequest")
obj = self.object.to_model(model=model, save=False, allow_create=False)
else:
obj = self.object.to_model(model=model, save=False, allow_create=False)
if not obj:
# if we don't have the object, we can't undo it. happens a lot with boosts
return
obj.delete()
@dataclass(init=False) @dataclass(init=False)
class Follow(Verb): class Follow(Verb):
''' Follow activity ''' """Follow activity"""
type: str = 'Follow'
object: str
type: str = "Follow"
def action(self):
"""relationship save"""
self.to_model()
@dataclass(init=False) @dataclass(init=False)
class Block(Verb): class Block(Verb):
''' Block activity ''' """Block activity"""
type: str = 'Block'
object: str
type: str = "Block"
def action(self):
"""relationship save"""
self.to_model()
@dataclass(init=False) @dataclass(init=False)
class Accept(Verb): class Accept(Verb):
''' Accept activity ''' """Accept activity"""
object: Follow object: Follow
type: str = 'Accept' type: str = "Accept"
def action(self):
"""find and remove the activity object"""
obj = self.object.to_model(save=False, allow_create=False)
obj.accept()
@dataclass(init=False) @dataclass(init=False)
class Reject(Verb): class Reject(Verb):
''' Reject activity ''' """Reject activity"""
object: Follow object: Follow
type: str = 'Reject' type: str = "Reject"
def action(self):
"""find and remove the activity object"""
obj = self.object.to_model(save=False, allow_create=False)
obj.reject()
@dataclass(init=False) @dataclass(init=False)
class Add(Verb): class Add(Verb):
'''Add activity ''' """Add activity"""
target: str
object: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class AddBook(Add):
'''Add activity that's aware of the book obj '''
object: Edition
type: str = 'Add'
@dataclass(init=False)
class AddListItem(AddBook):
'''Add activity that's aware of the book obj '''
notes: str = None
order: int = 0
approved: bool = True
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''
target: ActivityObject target: ActivityObject
type: str = 'Remove' object: CollectionItem
type: str = "Add"
def action(self):
"""figure out the target to assign the item to a collection"""
target = resolve_remote_id(self.target)
item = self.object.to_model(save=False)
setattr(item, item.collection_field, target)
item.save()
@dataclass(init=False)
class Remove(Add):
"""Remove activity"""
type: str = "Remove"
def action(self):
"""find and remove the activity object"""
obj = self.object.to_model(save=False, allow_create=False)
if obj:
obj.delete()
@dataclass(init=False)
class Like(Verb):
"""a user faving an object"""
object: str
type: str = "Like"
def action(self):
"""like"""
self.to_model()
@dataclass(init=False)
class Announce(Verb):
"""boosting a status"""
published: str
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
object: str
type: str = "Announce"
def action(self):
"""boost"""
self.to_model()

271
bookwyrm/activitystreams.py Normal file
View file

@ -0,0 +1,271 @@
""" access the activity streams stored in redis """
from django.dispatch import receiver
from django.db.models import signals, Q
from bookwyrm import models
from bookwyrm.redis_store import RedisStore, r
from bookwyrm.views.helpers import privacy_filter
class ActivityStream(RedisStore):
"""a category of activity stream (like home, local, federated)"""
def stream_id(self, user):
"""the redis key for this user's instance of this stream"""
return "{}-{}".format(user.id, self.key)
def unread_id(self, user):
"""the redis key for this user's unread count for this stream"""
return "{}-unread".format(self.stream_id(user))
def get_rank(self, obj): # pylint: disable=no-self-use
"""statuses are sorted by date published"""
return obj.published_date.timestamp()
def add_status(self, status):
"""add a status to users' feeds"""
# the pipeline contains all the add-to-stream activities
pipeline = self.add_object_to_related_stores(status, execute=False)
for user in self.get_audience(status):
# add to the unread status count
pipeline.incr(self.unread_id(user))
# and go!
pipeline.execute()
def add_user_statuses(self, viewer, user):
"""add a user's statuses to another user's feed"""
# only add the statuses that the viewer should be able to see (ie, not dms)
statuses = privacy_filter(viewer, user.status_set.all())
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
def remove_user_statuses(self, viewer, user):
"""remove a user's status from another user's feed"""
# remove all so that followers only statuses are removed
statuses = user.status_set.all()
self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
def get_activity_stream(self, user):
"""load the statuses to be displayed"""
# clear unreads for this feed
r.set(self.unread_id(user), 0)
statuses = self.get_store(self.stream_id(user))
return (
models.Status.objects.select_subclasses()
.filter(id__in=statuses)
.select_related("user", "reply_parent")
.prefetch_related("mention_books", "mention_users")
.order_by("-published_date")
)
def get_unread_count(self, user):
"""get the unread status count for this user's feed"""
return int(r.get(self.unread_id(user)) or 0)
def populate_streams(self, user):
"""go from zero to a timeline"""
self.populate_store(self.stream_id(user))
def get_audience(self, status): # pylint: disable=no-self-use
"""given a status, what users should see it"""
# direct messages don't appeard in feeds, direct comments/reviews/etc do
if status.privacy == "direct" and status.status_type == "Note":
return []
# everybody who could plausibly see this status
audience = models.User.objects.filter(
is_active=True,
local=True, # we only create feeds for users of this instance
).exclude(
Q(id__in=status.user.blocks.all()) | Q(blocks=status.user) # not blocked
)
# only visible to the poster and mentioned users
if status.privacy == "direct":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(id__in=status.mention_users.all()) # if the user is mentioned
)
# only visible to the poster's followers and tagged users
elif status.privacy == "followers":
audience = audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(following=status.user) # if the user is following the author
)
return audience.distinct()
def get_stores_for_object(self, obj):
return [self.stream_id(u) for u in self.get_audience(obj)]
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
"""given a user, what statuses should they see on this stream"""
return privacy_filter(
user,
models.Status.objects.select_subclasses(),
privacy_levels=["public", "unlisted", "followers"],
)
def get_objects_for_store(self, store):
user = models.User.objects.get(id=store.split("-")[0])
return self.get_statuses_for_user(user)
class HomeStream(ActivityStream):
"""users you follow"""
key = "home"
def get_audience(self, status):
audience = super().get_audience(status)
if not audience:
return []
return audience.filter(
Q(id=status.user.id) # if the user is the post's author
| Q(following=status.user) # if the user is following the author
).distinct()
def get_statuses_for_user(self, user):
return privacy_filter(
user,
models.Status.objects.select_subclasses(),
privacy_levels=["public", "unlisted", "followers"],
following_only=True,
)
class LocalStream(ActivityStream):
"""users you follow"""
key = "local"
def get_audience(self, status):
# this stream wants no part in non-public statuses
if status.privacy != "public" or not status.user.local:
return []
return super().get_audience(status)
def get_statuses_for_user(self, user):
# all public statuses by a local user
return privacy_filter(
user,
models.Status.objects.select_subclasses().filter(user__local=True),
privacy_levels=["public"],
)
class FederatedStream(ActivityStream):
"""users you follow"""
key = "federated"
def get_audience(self, status):
# this stream wants no part in non-public statuses
if status.privacy != "public":
return []
return super().get_audience(status)
def get_statuses_for_user(self, user):
return privacy_filter(
user,
models.Status.objects.select_subclasses(),
privacy_levels=["public"],
)
streams = {
"home": HomeStream(),
"local": LocalStream(),
"federated": FederatedStream(),
}
@receiver(signals.post_save)
# pylint: disable=unused-argument
def add_status_on_create(sender, instance, created, *args, **kwargs):
"""add newly created statuses to activity feeds"""
# we're only interested in new statuses
if not issubclass(sender, models.Status):
return
if instance.deleted:
for stream in streams.values():
stream.remove_object_from_related_stores(instance)
return
if not created:
return
# iterates through Home, Local, Federated
for stream in streams.values():
stream.add_status(instance)
@receiver(signals.post_delete, sender=models.Boost)
# pylint: disable=unused-argument
def remove_boost_on_delete(sender, instance, *args, **kwargs):
"""boosts are deleted"""
# we're only interested in new statuses
for stream in streams.values():
stream.remove_object_from_related_stores(instance)
@receiver(signals.post_save, sender=models.UserFollows)
# pylint: disable=unused-argument
def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
"""add a newly followed user's statuses to feeds"""
if not created or not instance.user_subject.local:
return
HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
@receiver(signals.post_delete, sender=models.UserFollows)
# pylint: disable=unused-argument
def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
"""remove statuses from a feed on unfollow"""
if not instance.user_subject.local:
return
HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
@receiver(signals.post_save, sender=models.UserBlocks)
# pylint: disable=unused-argument
def remove_statuses_on_block(sender, instance, *args, **kwargs):
"""remove statuses from all feeds on block"""
# blocks apply ot all feeds
if instance.user_subject.local:
for stream in streams.values():
stream.remove_user_statuses(instance.user_subject, instance.user_object)
# and in both directions
if instance.user_object.local:
for stream in streams.values():
stream.remove_user_statuses(instance.user_object, instance.user_subject)
@receiver(signals.post_delete, sender=models.UserBlocks)
# pylint: disable=unused-argument
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
"""remove statuses from all feeds on block"""
public_streams = [LocalStream(), FederatedStream()]
# add statuses back to streams with statuses from anyone
if instance.user_subject.local:
for stream in public_streams:
stream.add_user_statuses(instance.user_subject, instance.user_object)
# add statuses back to streams with statuses from anyone
if instance.user_object.local:
for stream in public_streams:
stream.add_user_statuses(instance.user_object, instance.user_subject)
@receiver(signals.post_save, sender=models.User)
# pylint: disable=unused-argument
def populate_streams_on_account_create(sender, instance, created, *args, **kwargs):
"""build a user's feeds when they join"""
if not created or not instance.local:
return
for stream in streams.values():
stream.populate_streams(instance)

View file

@ -1,8 +1,7 @@
''' models that will show up in django admin for superuser ''' """ models that will show up in django admin for superuser """
from django.contrib import admin from django.contrib import admin
from bookwyrm import models from bookwyrm import models
admin.site.register(models.SiteSettings)
admin.site.register(models.User) admin.site.register(models.User)
admin.site.register(models.FederatedServer) admin.site.register(models.FederatedServer)
admin.site.register(models.Connector) admin.site.register(models.Connector)

View file

@ -1,4 +1,4 @@
''' bring connectors into the namespace ''' """ bring connectors into the namespace """
from .settings import CONNECTORS from .settings import CONNECTORS
from .abstract_connector import ConnectorException from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image from .abstract_connector import get_data, get_image

View file

@ -1,4 +1,4 @@
''' functionality outline for a book data connector ''' """ functionality outline for a book data connector """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
import logging import logging
@ -13,8 +13,11 @@ from .connector_manager import load_more_data, ConnectorException
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class AbstractMinimalConnector(ABC): class AbstractMinimalConnector(ABC):
''' just the bare bones, for other bookwyrm instances ''' """just the bare bones, for other bookwyrm instances"""
def __init__(self, identifier): def __init__(self, identifier):
# load connector settings # load connector settings
info = models.Connector.objects.get(identifier=identifier) info = models.Connector.objects.get(identifier=identifier)
@ -22,88 +25,95 @@ class AbstractMinimalConnector(ABC):
# the things in the connector model to copy over # the things in the connector model to copy over
self_fields = [ self_fields = [
'base_url', "base_url",
'books_url', "books_url",
'covers_url', "covers_url",
'search_url', "search_url",
'max_query_count', "isbn_search_url",
'name', "name",
'identifier', "identifier",
'local' "local",
] ]
for field in self_fields: for field in self_fields:
setattr(self, field, getattr(info, field)) setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None): def search(self, query, min_confidence=None):
''' free text search ''' """free text search"""
params = {} params = {}
if min_confidence: if min_confidence:
params['min_confidence'] = min_confidence params["min_confidence"] = min_confidence
resp = requests.get( data = self.get_search_data(
'%s%s' % (self.search_url, query), "%s%s" % (self.search_url, query),
params=params, params=params,
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
},
) )
if not resp.ok:
resp.raise_for_status()
try:
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException('Unable to parse json response', e)
results = [] results = []
for doc in self.parse_search_data(data)[:10]: for doc in self.parse_search_data(data)[:10]:
results.append(self.format_search_result(doc)) results.append(self.format_search_result(doc))
return results return results
def isbn_search(self, query):
"""isbn search"""
params = {}
data = self.get_search_data(
"%s%s" % (self.isbn_search_url, query),
params=params,
)
results = []
# this shouldn't be returning mutliple results, but just in case
for doc in self.parse_isbn_search_data(data)[:10]:
results.append(self.format_isbn_search_result(doc))
return results
def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use
"""this allows connectors to override the default behavior"""
return get_data(remote_id, **kwargs)
@abstractmethod @abstractmethod
def get_or_create_book(self, remote_id): def get_or_create_book(self, remote_id):
''' pull up a book record by whatever means possible ''' """pull up a book record by whatever means possible"""
@abstractmethod @abstractmethod
def parse_search_data(self, data): def parse_search_data(self, data):
''' turn the result json from a search into a list ''' """turn the result json from a search into a list"""
@abstractmethod @abstractmethod
def format_search_result(self, search_result): def format_search_result(self, search_result):
''' create a SearchResult obj from json ''' """create a SearchResult obj from json"""
@abstractmethod
def parse_isbn_search_data(self, data):
"""turn the result json from a search into a list"""
@abstractmethod
def format_isbn_search_result(self, search_result):
"""create a SearchResult obj from json"""
class AbstractConnector(AbstractMinimalConnector): class AbstractConnector(AbstractMinimalConnector):
''' generic book data connector ''' """generic book data connector"""
def __init__(self, identifier): def __init__(self, identifier):
super().__init__(identifier) super().__init__(identifier)
# fields we want to look for in book data to copy over # fields we want to look for in book data to copy over
# title we handle separately. # title we handle separately.
self.book_mappings = [] self.book_mappings = []
def is_available(self):
''' check if you're allowed to use this connector '''
if self.max_query_count is not None:
if self.connector.query_count >= self.max_query_count:
return False
return True
def get_or_create_book(self, remote_id): def get_or_create_book(self, remote_id):
''' translate arbitrary json into an Activitypub dataclass ''' """translate arbitrary json into an Activitypub dataclass"""
# first, check if we have the origin_id saved # first, check if we have the origin_id saved
existing = models.Edition.find_existing_by_remote_id(remote_id) or \ existing = models.Edition.find_existing_by_remote_id(
models.Work.find_existing_by_remote_id(remote_id) remote_id
) or models.Work.find_existing_by_remote_id(remote_id)
if existing: if existing:
if hasattr(existing, 'get_default_editon'): if hasattr(existing, "default_edition"):
return existing.get_default_editon() return existing.default_edition
return existing return existing
# load the json # load the json
data = get_data(remote_id) data = self.get_book_data(remote_id)
mapped_data = dict_from_mappings(data, self.book_mappings)
if self.is_work_data(data): if self.is_work_data(data):
try: try:
edition_data = self.get_edition_from_work_data(data) edition_data = self.get_edition_from_work_data(data)
@ -111,44 +121,45 @@ class AbstractConnector(AbstractMinimalConnector):
# hack: re-use the work data as the edition data # hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique # this is why remote ids aren't necessarily unique
edition_data = data edition_data = data
work_data = mapped_data work_data = data
else: else:
edition_data = data
try: try:
work_data = self.get_work_from_edition_data(data) work_data = self.get_work_from_edition_data(data)
work_data = dict_from_mappings(work_data, self.book_mappings) except (KeyError, ConnectorException) as e:
except (KeyError, ConnectorException): logger.exception(e)
work_data = mapped_data work_data = data
edition_data = data
if not work_data or not edition_data: if not work_data or not edition_data:
raise ConnectorException('Unable to load book data: %s' % remote_id) raise ConnectorException("Unable to load book data: %s" % remote_id)
with transaction.atomic(): with transaction.atomic():
# create activitypub object # create activitypub object
work_activity = activitypub.Work(**work_data) work_activity = activitypub.Work(
**dict_from_mappings(work_data, self.book_mappings)
)
# this will dedupe automatically # this will dedupe automatically
work = work_activity.to_model(models.Work) work = work_activity.to_model(model=models.Work)
for author in self.get_authors_from_data(data): for author in self.get_authors_from_data(work_data):
work.authors.add(author) work.authors.add(author)
edition = self.create_edition_from_data(work, edition_data) edition = self.create_edition_from_data(work, edition_data)
load_more_data.delay(self.connector.id, work.id) load_more_data.delay(self.connector.id, work.id)
return edition return edition
def get_book_data(self, remote_id): # 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): def create_edition_from_data(self, work, edition_data):
''' if we already have the work, we're ready ''' """if we already have the work, we're ready"""
mapped_data = dict_from_mappings(edition_data, self.book_mappings) mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data['work'] = work.remote_id mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data) edition_activity = activitypub.Edition(**mapped_data)
edition = edition_activity.to_model(models.Edition) edition = edition_activity.to_model(model=models.Edition)
edition.connector = self.connector edition.connector = self.connector
edition.save() edition.save()
if not work.default_edition:
work.default_edition = edition
work.save()
for author in self.get_authors_from_data(edition_data): for author in self.get_authors_from_data(edition_data):
edition.authors.add(author) edition.authors.add(author)
if not edition.authors.exists() and work.authors.exists(): if not edition.authors.exists() and work.authors.exists():
@ -156,71 +167,80 @@ class AbstractConnector(AbstractMinimalConnector):
return edition return edition
def get_or_create_author(self, remote_id): def get_or_create_author(self, remote_id):
''' load that author ''' """load that author"""
existing = models.Author.find_existing_by_remote_id(remote_id) existing = models.Author.find_existing_by_remote_id(remote_id)
if existing: if existing:
return existing return existing
data = get_data(remote_id) data = self.get_book_data(remote_id)
mapped_data = dict_from_mappings(data, self.author_mappings) mapped_data = dict_from_mappings(data, self.author_mappings)
activity = activitypub.Author(**mapped_data) try:
# this will dedupe activity = activitypub.Author(**mapped_data)
return activity.to_model(models.Author) except activitypub.ActivitySerializerError:
return None
# this will dedupe
return activity.to_model(model=models.Author)
@abstractmethod @abstractmethod
def is_work_data(self, data): def is_work_data(self, data):
''' differentiate works and editions ''' """differentiate works and editions"""
@abstractmethod @abstractmethod
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
''' every work needs at least one edition ''' """every work needs at least one edition"""
@abstractmethod @abstractmethod
def get_work_from_edition_data(self, data): def get_work_from_edition_data(self, data):
''' every edition needs a work ''' """every edition needs a work"""
@abstractmethod @abstractmethod
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
''' load author data ''' """load author data"""
@abstractmethod @abstractmethod
def expand_book_data(self, book): def expand_book_data(self, book):
''' get more info on a book ''' """get more info on a book"""
def dict_from_mappings(data, mappings): def dict_from_mappings(data, mappings):
''' create a dict in Activitypub format, using mappings supplies by """create a dict in Activitypub format, using mappings supplies by
the subclass ''' the subclass"""
result = {} result = {}
for mapping in mappings: for mapping in mappings:
# sometimes there are multiple mappings for one field, don't
# overwrite earlier writes in that case
if mapping.local_field in result and result[mapping.local_field]:
continue
result[mapping.local_field] = mapping.get_value(data) result[mapping.local_field] = mapping.get_value(data)
return result return result
def get_data(url): def get_data(url, params=None):
''' wrapper for request.get ''' """wrapper for request.get"""
# check if the url is blocked
if models.FederatedServer.is_blocked(url):
raise ConnectorException(
"Attempting to load data from blocked url: {:s}".format(url)
)
try: try:
resp = requests.get( resp = requests.get(
url, url,
params=params,
headers={ headers={
'Accept': 'application/json; charset=utf-8', "Accept": "application/json; charset=utf-8",
'User-Agent': settings.USER_AGENT, "User-Agent": settings.USER_AGENT,
}, },
) )
except (RequestError, SSLError) as e: except (RequestError, SSLError, ConnectionError) as e:
logger.exception(e) logger.exception(e)
raise ConnectorException() raise ConnectorException()
if not resp.ok: if not resp.ok:
try: raise ConnectorException()
resp.raise_for_status()
except requests.exceptions.HTTPError as e:
logger.exception(e)
raise ConnectorException()
try: try:
data = resp.json() data = resp.json()
except ValueError as e: except ValueError as e:
@ -231,12 +251,12 @@ def get_data(url):
def get_image(url): def get_image(url):
''' wrapper for requesting an image ''' """wrapper for requesting an image"""
try: try:
resp = requests.get( resp = requests.get(
url, url,
headers={ headers={
'User-Agent': settings.USER_AGENT, "User-Agent": settings.USER_AGENT,
}, },
) )
except (RequestError, SSLError) as e: except (RequestError, SSLError) as e:
@ -249,27 +269,32 @@ def get_image(url):
@dataclass @dataclass
class SearchResult: class SearchResult:
''' standardized search result object ''' """standardized search result object"""
title: str title: str
key: str key: str
author: str
year: str
connector: object connector: object
view_link: str = None
author: str = None
year: str = None
cover: str = None
confidence: int = 1 confidence: int = 1
def __repr__(self): def __repr__(self):
return "<SearchResult key={!r} title={!r} author={!r}>".format( return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author) self.key, self.title, self.author
)
def json(self): def json(self):
''' serialize a connector for json response ''' """serialize a connector for json response"""
serialized = asdict(self) serialized = asdict(self)
del serialized['connector'] del serialized["connector"]
return serialized return serialized
class Mapping: class Mapping:
''' associate a local database field with a field in an external dataset ''' """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, remote_field=None, formatter=None):
noop = lambda x: x noop = lambda x: x
@ -278,11 +303,11 @@ class Mapping:
self.formatter = formatter or noop self.formatter = formatter or noop
def get_value(self, data): def get_value(self, data):
''' pull a field from incoming json and return the formatted version ''' """pull a field from incoming json and return the formatted version"""
value = data.get(self.remote_field) value = data.get(self.remote_field)
if not value: if not value:
return None return None
try: try:
return self.formatter(value) return self.formatter(value)
except:# pylint: disable=bare-except except: # pylint: disable=bare-except
return None return None

View file

@ -1,21 +1,23 @@
''' using another bookwyrm instance as a source of book data ''' """ using another bookwyrm instance as a source of book data """
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
from .abstract_connector import AbstractMinimalConnector, SearchResult from .abstract_connector import AbstractMinimalConnector, SearchResult
class Connector(AbstractMinimalConnector): class Connector(AbstractMinimalConnector):
''' this is basically just for search ''' """this is basically just for search"""
def get_or_create_book(self, remote_id): def get_or_create_book(self, remote_id):
edition = activitypub.resolve_remote_id(models.Edition, remote_id) return activitypub.resolve_remote_id(remote_id, model=models.Edition)
work = edition.parent_work
work.default_edition = work.get_default_edition()
work.save()
return edition
def parse_search_data(self, data): def parse_search_data(self, data):
return data return data
def format_search_result(self, search_result): def format_search_result(self, search_result):
search_result['connector'] = self search_result["connector"] = self
return SearchResult(**search_result) return SearchResult(**search_result)
def parse_isbn_search_data(self, data):
return data
def format_isbn_search_result(self, search_result):
return self.format_search_result(search_result)

View file

@ -1,79 +1,114 @@
''' interface with whatever connectors the app has ''' """ interface with whatever connectors the app has """
import importlib import importlib
import logging
import re
from urllib.parse import urlparse from urllib.parse import urlparse
from django.dispatch import receiver
from django.db.models import signals
from requests import HTTPError from requests import HTTPError
from bookwyrm import models from bookwyrm import models
from bookwyrm.tasks import app from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
class ConnectorException(HTTPError): class ConnectorException(HTTPError):
''' when the connector can't do what was asked ''' """when the connector can't do what was asked"""
def search(query, min_confidence=0.1): def search(query, min_confidence=0.1, return_first=False):
''' find books based on arbitary keywords ''' """find books based on arbitary keywords"""
if not query:
return []
results = [] results = []
dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year)
result_index = set()
for connector in get_connectors():
try:
result_set = connector.search(query, min_confidence=min_confidence)
except (HTTPError, ConnectorException):
continue
result_set = [r for r in result_set \ # Have we got a ISBN ?
if dedup_slug(r) not in result_index] isbn = re.sub(r"[\W_]", "", query)
# `|=` concats two sets. WE ARE GETTING FANCY HERE maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
result_index |= set(dedup_slug(r) for r in result_set)
results.append({ for connector in get_connectors():
'connector': connector, result_set = None
'results': result_set, if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url == "":
}) # Search on ISBN
try:
result_set = connector.isbn_search(isbn)
except Exception as e: # pylint: disable=broad-except
logger.exception(e)
# if this fails, we can still try regular search
# if no isbn search results, we fallback to generic search
if not result_set:
try:
result_set = connector.search(query, min_confidence=min_confidence)
except Exception as e: # pylint: disable=broad-except
# we don't want *any* error to crash the whole search page
logger.exception(e)
continue
if return_first and result_set:
# if we found anything, return it
return result_set[0]
if result_set or connector.local:
results.append(
{
"connector": connector,
"results": result_set,
}
)
if return_first:
return None
return results return results
def local_search(query, min_confidence=0.1, raw=False): def local_search(query, min_confidence=0.1, raw=False, filters=None):
''' only look at local search results ''' """only look at local search results"""
connector = load_connector(models.Connector.objects.get(local=True)) connector = load_connector(models.Connector.objects.get(local=True))
return connector.search(query, min_confidence=min_confidence, raw=raw) return connector.search(
query, min_confidence=min_confidence, raw=raw, filters=filters
)
def isbn_local_search(query, raw=False):
"""only look at local search results"""
connector = load_connector(models.Connector.objects.get(local=True))
return connector.isbn_search(query, raw=raw)
def first_search_result(query, min_confidence=0.1): def first_search_result(query, min_confidence=0.1):
''' search until you find a result that fits ''' """search until you find a result that fits"""
for connector in get_connectors(): return search(query, min_confidence=min_confidence, return_first=True) or None
result = connector.search(query, min_confidence=min_confidence)
if result:
return result[0]
return None
def get_connectors(): def get_connectors():
''' load all connectors ''' """load all connectors"""
for info in models.Connector.objects.order_by('priority').all(): for info in models.Connector.objects.filter(active=True).order_by("priority").all():
yield load_connector(info) yield load_connector(info)
def get_or_create_connector(remote_id): def get_or_create_connector(remote_id):
''' get the connector related to the author's server ''' """get the connector related to the object's server"""
url = urlparse(remote_id) url = urlparse(remote_id)
identifier = url.netloc identifier = url.netloc
if not identifier: if not identifier:
raise ValueError('Invalid remote id') raise ValueError("Invalid remote id")
try: try:
connector_info = models.Connector.objects.get(identifier=identifier) connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist: except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create( connector_info = models.Connector.objects.create(
identifier=identifier, identifier=identifier,
connector_file='bookwyrm_connector', connector_file="bookwyrm_connector",
base_url='https://%s' % identifier, base_url="https://%s" % identifier,
books_url='https://%s/book' % identifier, books_url="https://%s/book" % identifier,
covers_url='https://%s/images/covers' % identifier, covers_url="https://%s/images/covers" % identifier,
search_url='https://%s/search?q=' % identifier, search_url="https://%s/search?q=" % identifier,
priority=2 priority=2,
) )
return load_connector(connector_info) return load_connector(connector_info)
@ -81,7 +116,7 @@ def get_or_create_connector(remote_id):
@app.task @app.task
def load_more_data(connector_id, book_id): def load_more_data(connector_id, book_id):
''' background the work of getting all 10,000 editions of LoTR ''' """background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id) connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info) connector = load_connector(connector_info)
book = models.Book.objects.select_subclasses().get(id=book_id) book = models.Book.objects.select_subclasses().get(id=book_id)
@ -89,8 +124,16 @@ def load_more_data(connector_id, book_id):
def load_connector(connector_info): def load_connector(connector_info):
''' instantiate the connector class ''' """instantiate the connector class"""
connector = importlib.import_module( connector = importlib.import_module(
'bookwyrm.connectors.%s' % connector_info.connector_file "bookwyrm.connectors.%s" % connector_info.connector_file
) )
return connector.Connector(connector_info.identifier) return connector.Connector(connector_info.identifier)
@receiver(signals.post_save, sender="bookwyrm.FederatedServer")
# pylint: disable=unused-argument
def create_connector(sender, instance, created, *args, **kwargs):
"""create a connector to an external bookwyrm server"""
if instance.application_type == "bookwyrm":
get_or_create_connector("https://{:s}".format(instance.server_name))

View file

@ -0,0 +1,232 @@
""" inventaire data connector """
import re
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import get_data
from .connector_manager import ConnectorException
class Connector(AbstractConnector):
"""instantiate a connector for OL"""
def __init__(self, identifier):
super().__init__(identifier)
get_first = lambda a: a[0]
shared_mappings = [
Mapping("id", remote_field="uri", formatter=self.get_remote_id),
Mapping("bnfId", remote_field="wdt:P268", formatter=get_first),
Mapping("openlibraryKey", remote_field="wdt:P648", formatter=get_first),
]
self.book_mappings = [
Mapping("title", remote_field="wdt:P1476", formatter=get_first),
Mapping("title", remote_field="labels", formatter=get_language_code),
Mapping("subtitle", remote_field="wdt:P1680", formatter=get_first),
Mapping("inventaireId", remote_field="uri"),
Mapping(
"description", remote_field="sitelinks", formatter=self.get_description
),
Mapping("cover", remote_field="image", formatter=self.get_cover_url),
Mapping("isbn13", remote_field="wdt:P212", formatter=get_first),
Mapping("isbn10", remote_field="wdt:P957", formatter=get_first),
Mapping("oclcNumber", remote_field="wdt:P5331", formatter=get_first),
Mapping("goodreadsKey", remote_field="wdt:P2969", formatter=get_first),
Mapping("librarythingKey", remote_field="wdt:P1085", formatter=get_first),
Mapping("languages", remote_field="wdt:P407", formatter=self.resolve_keys),
Mapping("publishers", remote_field="wdt:P123", formatter=self.resolve_keys),
Mapping("publishedDate", remote_field="wdt:P577", formatter=get_first),
Mapping("pages", remote_field="wdt:P1104", formatter=get_first),
Mapping(
"subjectPlaces", remote_field="wdt:P840", formatter=self.resolve_keys
),
Mapping("subjects", remote_field="wdt:P921", formatter=self.resolve_keys),
Mapping("asin", remote_field="wdt:P5749", formatter=get_first),
] + shared_mappings
# TODO: P136: genre, P674 characters, P950 bne
self.author_mappings = [
Mapping("id", remote_field="uri", formatter=self.get_remote_id),
Mapping("name", remote_field="labels", formatter=get_language_code),
Mapping("bio", remote_field="sitelinks", formatter=self.get_description),
Mapping("goodreadsKey", remote_field="wdt:P2963", formatter=get_first),
Mapping("isni", remote_field="wdt:P213", formatter=get_first),
Mapping("viafId", remote_field="wdt:P214", formatter=get_first),
Mapping("gutenberg_id", remote_field="wdt:P1938", formatter=get_first),
Mapping("born", remote_field="wdt:P569", formatter=get_first),
Mapping("died", remote_field="wdt:P570", formatter=get_first),
] + shared_mappings
def get_remote_id(self, value):
"""convert an id/uri into a url"""
return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value)
def get_book_data(self, remote_id):
data = get_data(remote_id)
extracted = list(data.get("entities").values())
try:
data = extracted[0]
except KeyError:
raise ConnectorException("Invalid book data")
# 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"]},
}
def search(self, query, min_confidence=None):
"""overrides default search function with confidence ranking"""
results = super().search(query)
if min_confidence:
# filter the search results after the fact
return [r for r in results if r.confidence >= min_confidence]
return results
def parse_search_data(self, data):
return data.get("results")
def format_search_result(self, search_result):
images = search_result.get("image")
cover = (
"{:s}/img/entities/{:s}".format(self.covers_url, images[0])
if images
else None
)
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
return SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link="{:s}/entity/{:s}".format(
self.base_url, search_result.get("uri")
),
cover=cover,
confidence=confidence,
connector=self,
)
def parse_isbn_search_data(self, data):
"""got some daaaata"""
results = data.get("entities")
if not results:
return []
return list(results.values())
def format_isbn_search_result(self, search_result):
"""totally different format than a regular search result"""
title = search_result.get("claims", {}).get("wdt:P1476", [])
if not title:
return None
return SearchResult(
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link="{:s}/entity/{:s}".format(
self.base_url, search_result.get("uri")
),
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
def is_work_data(self, data):
return data.get("type") == "work"
def load_edition_data(self, work_uri):
"""get a list of editions for a work"""
url = (
"{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format(
self.books_url, work_uri
)
)
return get_data(url)
def get_edition_from_work_data(self, data):
data = self.load_edition_data(data.get("uri"))
try:
uri = data["uris"][0]
except KeyError:
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]
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):
authors = data.get("wdt:P50", [])
for author in authors:
yield self.get_or_create_author(self.get_remote_id(author))
def expand_book_data(self, book):
work = book
# go from the edition to the work, if necessary
if isinstance(book, models.Edition):
work = book.parent_work
try:
edition_options = self.load_edition_data(work.inventaire_id)
except ConnectorException:
# who knows, man
return
for edition_uri in edition_options.get("uris"):
remote_id = self.get_remote_id(edition_uri)
try:
data = self.get_book_data(remote_id)
except ConnectorException:
# who, indeed, knows
continue
self.create_edition_from_data(work, data)
def get_cover_url(self, cover_blob, *_):
"""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:
cover_blob = cover_blob[0]
cover_id = cover_blob.get("url")
if not cover_id:
return None
# cover may or may not be an absolute url already
if re.match(r"^http", cover_id):
return cover_id
return "%s%s" % (self.covers_url, cover_id)
def resolve_keys(self, keys):
"""cool, it's "wd:Q3156592" now what the heck does that mean"""
results = []
for uri in keys:
try:
data = self.get_book_data(self.get_remote_id(uri))
except ConnectorException:
continue
results.append(get_language_code(data.get("labels")))
return results
def get_description(self, links):
"""grab an extracted excerpt from wikipedia"""
link = links.get("enwiki")
if not link:
return ""
url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format(
self.base_url, link
)
try:
data = get_data(url)
except ConnectorException:
return ""
return data.get("extract")
def get_language_code(options, code="en"):
"""when there are a bunch of translation but we need a single field"""
result = options.get(code)
if result:
return result
values = list(options.values())
return values[0] if values else None

View file

@ -1,4 +1,4 @@
''' openlibrary data connector ''' """ openlibrary data connector """
import re import re
from bookwyrm import models from bookwyrm import models
@ -9,131 +9,151 @@ from .openlibrary_languages import languages
class Connector(AbstractConnector): class Connector(AbstractConnector):
''' instantiate a connector for OL ''' """instantiate a connector for OL"""
def __init__(self, identifier): def __init__(self, identifier):
super().__init__(identifier) super().__init__(identifier)
get_first = lambda a: a[0] get_first = lambda a, *args: a[0]
get_remote_id = lambda a: self.base_url + a get_remote_id = lambda a, *args: self.base_url + a
self.book_mappings = [ self.book_mappings = [
Mapping('title'), Mapping("title"),
Mapping('id', remote_field='key', formatter=get_remote_id), Mapping("id", remote_field="key", formatter=get_remote_id),
Mapping("cover", remote_field="covers", formatter=self.get_cover_url),
Mapping("sortTitle", remote_field="sort_title"),
Mapping("subtitle"),
Mapping("description", formatter=get_description),
Mapping("languages", formatter=get_languages),
Mapping("series", formatter=get_first),
Mapping("seriesNumber", remote_field="series_number"),
Mapping("subjects"),
Mapping("subjectPlaces", remote_field="subject_places"),
Mapping("isbn13", remote_field="isbn_13", formatter=get_first),
Mapping("isbn10", remote_field="isbn_10", formatter=get_first),
Mapping("lccn", formatter=get_first),
Mapping("oclcNumber", remote_field="oclc_numbers", formatter=get_first),
Mapping( Mapping(
'cover', remote_field='covers', formatter=self.get_cover_url), "openlibraryKey", remote_field="key", formatter=get_openlibrary_key
Mapping('sortTitle', remote_field='sort_title'),
Mapping('subtitle'),
Mapping('description', formatter=get_description),
Mapping('languages', formatter=get_languages),
Mapping('series', formatter=get_first),
Mapping('seriesNumber', remote_field='series_number'),
Mapping('subjects'),
Mapping('subjectPlaces', remote_field='subject_places'),
Mapping('isbn13', remote_field='isbn_13', formatter=get_first),
Mapping('isbn10', remote_field='isbn_10', formatter=get_first),
Mapping('lccn', formatter=get_first),
Mapping(
'oclcNumber', remote_field='oclc_numbers',
formatter=get_first
), ),
Mapping("goodreadsKey", remote_field="goodreads_key"),
Mapping("asin"),
Mapping( Mapping(
'openlibraryKey', remote_field='key', "firstPublishedDate",
formatter=get_openlibrary_key remote_field="first_publish_date",
), ),
Mapping('goodreadsKey', remote_field='goodreads_key'), Mapping("publishedDate", remote_field="publish_date"),
Mapping('asin'), Mapping("pages", remote_field="number_of_pages"),
Mapping( Mapping("physicalFormat", remote_field="physical_format"),
'firstPublishedDate', remote_field='first_publish_date', Mapping("publishers"),
),
Mapping('publishedDate', remote_field='publish_date'),
Mapping('pages', remote_field='number_of_pages'),
Mapping('physicalFormat', remote_field='physical_format'),
Mapping('publishers'),
] ]
self.author_mappings = [ self.author_mappings = [
Mapping('id', remote_field='key', formatter=get_remote_id), Mapping("id", remote_field="key", formatter=get_remote_id),
Mapping('name'), Mapping("name"),
Mapping( Mapping(
'openlibraryKey', remote_field='key', "openlibraryKey", remote_field="key", formatter=get_openlibrary_key
formatter=get_openlibrary_key
), ),
Mapping('born', remote_field='birth_date'), Mapping("born", remote_field="birth_date"),
Mapping('died', remote_field='death_date'), Mapping("died", remote_field="death_date"),
Mapping('bio', formatter=get_description), Mapping("bio", formatter=get_description),
] ]
def get_book_data(self, remote_id):
data = get_data(remote_id)
if data.get("type", {}).get("key") == "/type/redirect":
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):
''' format a url from an openlibrary id field ''' """format a url from an openlibrary id field"""
try: try:
key = data['key'] key = data["key"]
except KeyError: except KeyError:
raise ConnectorException('Invalid book data') raise ConnectorException("Invalid book data")
return '%s%s' % (self.books_url, key) return "%s%s" % (self.books_url, key)
def is_work_data(self, data): def is_work_data(self, data):
return bool(re.match(r'^[\/\w]+OL\d+W$', data['key'])) 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):
try: try:
key = data['key'] key = data["key"]
except KeyError: except KeyError:
raise ConnectorException('Invalid book data') raise ConnectorException("Invalid book data")
url = '%s%s/editions' % (self.books_url, key) url = "%s%s/editions" % (self.books_url, key)
data = get_data(url) data = self.get_book_data(url)
return pick_default_edition(data['entries']) edition = pick_default_edition(data["entries"])
if not edition:
raise ConnectorException("No editions for work")
return edition
def get_work_from_edition_data(self, data): def get_work_from_edition_data(self, data):
try: try:
key = data['works'][0]['key'] key = data["works"][0]["key"]
except (IndexError, KeyError): except (IndexError, KeyError):
raise ConnectorException('No work found for edition') raise ConnectorException("No work found for edition")
url = '%s%s' % (self.books_url, key) url = "%s%s" % (self.books_url, key)
return get_data(url) return self.get_book_data(url)
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
''' parse author json and load or create authors ''' """parse author json and load or create authors"""
for author_blob in data.get('authors', []): for author_blob in data.get("authors", []):
author_blob = author_blob.get('author', author_blob) author_blob = author_blob.get("author", author_blob)
# this id is "/authors/OL1234567A" # this id is "/authors/OL1234567A"
author_id = author_blob['key'] author_id = author_blob["key"]
url = '%s%s' % (self.base_url, author_id) url = "%s%s" % (self.base_url, author_id)
yield self.get_or_create_author(url) author = self.get_or_create_author(url)
if not author:
continue
yield author
def get_cover_url(self, cover_blob, size="L"):
def get_cover_url(self, cover_blob): """ask openlibrary for the cover"""
''' ask openlibrary for the cover ''' if not cover_blob:
return None
cover_id = cover_blob[0] cover_id = cover_blob[0]
image_name = '%s-L.jpg' % cover_id image_name = "%s-%s.jpg" % (cover_id, size)
return '%s/b/id/%s' % (self.covers_url, image_name) return "%s/b/id/%s" % (self.covers_url, image_name)
def parse_search_data(self, data): def parse_search_data(self, data):
return data.get('docs') return data.get("docs")
def format_search_result(self, search_result): def format_search_result(self, search_result):
# build the remote id from the openlibrary key # build the remote id from the openlibrary key
key = self.books_url + search_result['key'] key = self.books_url + search_result["key"]
author = search_result.get('author_name') or ['Unknown'] author = search_result.get("author_name") or ["Unknown"]
cover_blob = search_result.get("cover_i")
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
return SearchResult( return SearchResult(
title=search_result.get('title'), title=search_result.get("title"),
key=key, key=key,
author=', '.join(author), author=", ".join(author),
connector=self, connector=self,
year=search_result.get('first_publish_year'), year=search_result.get("first_publish_year"),
cover=cover,
) )
def parse_isbn_search_data(self, data):
return list(data.values())
def format_isbn_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
return SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author_names),
connector=self,
year=search_result.get("publish_date"),
)
def load_edition_data(self, olkey): def load_edition_data(self, olkey):
''' query openlibrary for editions of a work ''' """query openlibrary for editions of a work"""
url = '%s/works/%s/editions' % (self.books_url, olkey) url = "%s/works/%s/editions" % (self.books_url, olkey)
return get_data(url) return self.get_book_data(url)
def expand_book_data(self, book): def expand_book_data(self, book):
work = book work = book
@ -148,7 +168,7 @@ class Connector(AbstractConnector):
# who knows, man # who knows, man
return return
for edition_data in edition_options.get('entries'): for edition_data in edition_options.get("entries"):
# does this edition have ANY interesting data? # does this edition have ANY interesting data?
if ignore_edition(edition_data): if ignore_edition(edition_data):
continue continue
@ -156,62 +176,59 @@ class Connector(AbstractConnector):
def ignore_edition(edition_data): def ignore_edition(edition_data):
''' don't load a million editions that have no metadata ''' """don't load a million editions that have no metadata"""
# an isbn, we love to see it # an isbn, we love to see it
if edition_data.get('isbn_13') or edition_data.get('isbn_10'): if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
print(edition_data.get('isbn_10'))
return False return False
# grudgingly, oclc can stay # grudgingly, oclc can stay
if edition_data.get('oclc_numbers'): if edition_data.get("oclc_numbers"):
print(edition_data.get('oclc_numbers'))
return False return False
# if it has a cover it can stay # if it has a cover it can stay
if edition_data.get('covers'): if edition_data.get("covers"):
print(edition_data.get('covers'))
return False return False
# keep non-english editions # keep non-english editions
if edition_data.get('languages') and \ if edition_data.get("languages") and "languages/eng" not in str(
'languages/eng' not in str(edition_data.get('languages')): edition_data.get("languages")
print(edition_data.get('languages')) ):
return False return False
return True return True
def get_description(description_blob): def get_description(description_blob):
''' descriptions can be a string or a dict ''' """descriptions can be a string or a dict"""
if isinstance(description_blob, dict): if isinstance(description_blob, dict):
return description_blob.get('value') return description_blob.get("value")
return description_blob return description_blob
def get_openlibrary_key(key): def get_openlibrary_key(key):
''' convert /books/OL27320736M into OL27320736M ''' """convert /books/OL27320736M into OL27320736M"""
return key.split('/')[-1] return key.split("/")[-1]
def get_languages(language_blob): def get_languages(language_blob):
''' /language/eng -> English ''' """/language/eng -> English"""
langs = [] langs = []
for lang in language_blob: for lang in language_blob:
langs.append( langs.append(languages.get(lang.get("key", ""), None))
languages.get(lang.get('key', ''), None)
)
return langs return langs
def pick_default_edition(options): def pick_default_edition(options):
''' favor physical copies with covers in english ''' """favor physical copies with covers in english"""
if not options: if not options:
return None return None
if len(options) == 1: if len(options) == 1:
return options[0] return options[0]
options = [e for e in options if e.get('covers')] or options options = [e for e in options if e.get("covers")] or options
options = [e for e in options if \ options = [
'/languages/eng' in str(e.get('languages'))] or options e for e in options if "/languages/eng" in str(e.get("languages"))
formats = ['paperback', 'hardcover', 'mass market paperback'] ] or options
options = [e for e in options if \ formats = ["paperback", "hardcover", "mass market paperback"]
str(e.get('physical_format')).lower() in formats] or options options = [
options = [e for e in options if e.get('isbn_13')] or options e for e in options if str(e.get("physical_format")).lower() in formats
options = [e for e in options if e.get('ocaid')] or options ] or options
options = [e for e in options if e.get("isbn_13")] or options
options = [e for e in options if e.get("ocaid")] or options
return options[0] return options[0]

View file

@ -1,467 +1,467 @@
''' key lookups for openlibrary languages ''' """ key lookups for openlibrary languages """
languages = { languages = {
'/languages/eng': 'English', "/languages/eng": "English",
'/languages/fre': 'French', "/languages/fre": "French",
'/languages/spa': 'Spanish', "/languages/spa": "Spanish",
'/languages/ger': 'German', "/languages/ger": "German",
'/languages/rus': 'Russian', "/languages/rus": "Russian",
'/languages/ita': 'Italian', "/languages/ita": "Italian",
'/languages/chi': 'Chinese', "/languages/chi": "Chinese",
'/languages/jpn': 'Japanese', "/languages/jpn": "Japanese",
'/languages/por': 'Portuguese', "/languages/por": "Portuguese",
'/languages/ara': 'Arabic', "/languages/ara": "Arabic",
'/languages/pol': 'Polish', "/languages/pol": "Polish",
'/languages/heb': 'Hebrew', "/languages/heb": "Hebrew",
'/languages/kor': 'Korean', "/languages/kor": "Korean",
'/languages/dut': 'Dutch', "/languages/dut": "Dutch",
'/languages/ind': 'Indonesian', "/languages/ind": "Indonesian",
'/languages/lat': 'Latin', "/languages/lat": "Latin",
'/languages/und': 'Undetermined', "/languages/und": "Undetermined",
'/languages/cmn': 'Mandarin', "/languages/cmn": "Mandarin",
'/languages/hin': 'Hindi', "/languages/hin": "Hindi",
'/languages/swe': 'Swedish', "/languages/swe": "Swedish",
'/languages/dan': 'Danish', "/languages/dan": "Danish",
'/languages/urd': 'Urdu', "/languages/urd": "Urdu",
'/languages/hun': 'Hungarian', "/languages/hun": "Hungarian",
'/languages/cze': 'Czech', "/languages/cze": "Czech",
'/languages/tur': 'Turkish', "/languages/tur": "Turkish",
'/languages/ukr': 'Ukrainian', "/languages/ukr": "Ukrainian",
'/languages/gre': 'Greek', "/languages/gre": "Greek",
'/languages/vie': 'Vietnamese', "/languages/vie": "Vietnamese",
'/languages/bul': 'Bulgarian', "/languages/bul": "Bulgarian",
'/languages/ben': 'Bengali', "/languages/ben": "Bengali",
'/languages/rum': 'Romanian', "/languages/rum": "Romanian",
'/languages/cat': 'Catalan', "/languages/cat": "Catalan",
'/languages/nor': 'Norwegian', "/languages/nor": "Norwegian",
'/languages/tha': 'Thai', "/languages/tha": "Thai",
'/languages/per': 'Persian', "/languages/per": "Persian",
'/languages/scr': 'Croatian', "/languages/scr": "Croatian",
'/languages/mul': 'Multiple languages', "/languages/mul": "Multiple languages",
'/languages/fin': 'Finnish', "/languages/fin": "Finnish",
'/languages/tam': 'Tamil', "/languages/tam": "Tamil",
'/languages/guj': 'Gujarati', "/languages/guj": "Gujarati",
'/languages/mar': 'Marathi', "/languages/mar": "Marathi",
'/languages/scc': 'Serbian', "/languages/scc": "Serbian",
'/languages/pan': 'Panjabi', "/languages/pan": "Panjabi",
'/languages/wel': 'Welsh', "/languages/wel": "Welsh",
'/languages/tel': 'Telugu', "/languages/tel": "Telugu",
'/languages/yid': 'Yiddish', "/languages/yid": "Yiddish",
'/languages/kan': 'Kannada', "/languages/kan": "Kannada",
'/languages/slo': 'Slovak', "/languages/slo": "Slovak",
'/languages/san': 'Sanskrit', "/languages/san": "Sanskrit",
'/languages/arm': 'Armenian', "/languages/arm": "Armenian",
'/languages/mal': 'Malayalam', "/languages/mal": "Malayalam",
'/languages/may': 'Malay', "/languages/may": "Malay",
'/languages/bur': 'Burmese', "/languages/bur": "Burmese",
'/languages/slv': 'Slovenian', "/languages/slv": "Slovenian",
'/languages/lit': 'Lithuanian', "/languages/lit": "Lithuanian",
'/languages/tib': 'Tibetan', "/languages/tib": "Tibetan",
'/languages/lav': 'Latvian', "/languages/lav": "Latvian",
'/languages/est': 'Estonian', "/languages/est": "Estonian",
'/languages/nep': 'Nepali', "/languages/nep": "Nepali",
'/languages/ori': 'Oriya', "/languages/ori": "Oriya",
'/languages/mon': 'Mongolian', "/languages/mon": "Mongolian",
'/languages/alb': 'Albanian', "/languages/alb": "Albanian",
'/languages/iri': 'Irish', "/languages/iri": "Irish",
'/languages/geo': 'Georgian', "/languages/geo": "Georgian",
'/languages/afr': 'Afrikaans', "/languages/afr": "Afrikaans",
'/languages/grc': 'Ancient Greek', "/languages/grc": "Ancient Greek",
'/languages/mac': 'Macedonian', "/languages/mac": "Macedonian",
'/languages/bel': 'Belarusian', "/languages/bel": "Belarusian",
'/languages/ice': 'Icelandic', "/languages/ice": "Icelandic",
'/languages/srp': 'Serbian', "/languages/srp": "Serbian",
'/languages/snh': 'Sinhalese', "/languages/snh": "Sinhalese",
'/languages/snd': 'Sindhi', "/languages/snd": "Sindhi",
'/languages/ota': 'Turkish, Ottoman', "/languages/ota": "Turkish, Ottoman",
'/languages/kur': 'Kurdish', "/languages/kur": "Kurdish",
'/languages/aze': 'Azerbaijani', "/languages/aze": "Azerbaijani",
'/languages/pus': 'Pushto', "/languages/pus": "Pushto",
'/languages/amh': 'Amharic', "/languages/amh": "Amharic",
'/languages/gag': 'Galician', "/languages/gag": "Galician",
'/languages/hrv': 'Croatian', "/languages/hrv": "Croatian",
'/languages/sin': 'Sinhalese', "/languages/sin": "Sinhalese",
'/languages/asm': 'Assamese', "/languages/asm": "Assamese",
'/languages/uzb': 'Uzbek', "/languages/uzb": "Uzbek",
'/languages/gae': 'Scottish Gaelix', "/languages/gae": "Scottish Gaelix",
'/languages/kaz': 'Kazakh', "/languages/kaz": "Kazakh",
'/languages/swa': 'Swahili', "/languages/swa": "Swahili",
'/languages/bos': 'Bosnian', "/languages/bos": "Bosnian",
'/languages/glg': 'Galician ', "/languages/glg": "Galician ",
'/languages/baq': 'Basque', "/languages/baq": "Basque",
'/languages/tgl': 'Tagalog', "/languages/tgl": "Tagalog",
'/languages/raj': 'Rajasthani', "/languages/raj": "Rajasthani",
'/languages/gle': 'Irish', "/languages/gle": "Irish",
'/languages/lao': 'Lao', "/languages/lao": "Lao",
'/languages/jav': 'Javanese', "/languages/jav": "Javanese",
'/languages/mai': 'Maithili', "/languages/mai": "Maithili",
'/languages/tgk': 'Tajik ', "/languages/tgk": "Tajik ",
'/languages/khm': 'Khmer', "/languages/khm": "Khmer",
'/languages/roh': 'Raeto-Romance', "/languages/roh": "Raeto-Romance",
'/languages/kok': 'Konkani ', "/languages/kok": "Konkani ",
'/languages/sit': 'Sino-Tibetan (Other)', "/languages/sit": "Sino-Tibetan (Other)",
'/languages/mol': 'Moldavian', "/languages/mol": "Moldavian",
'/languages/kir': 'Kyrgyz', "/languages/kir": "Kyrgyz",
'/languages/new': 'Newari', "/languages/new": "Newari",
'/languages/inc': 'Indic (Other)', "/languages/inc": "Indic (Other)",
'/languages/frm': 'French, Middle (ca. 1300-1600)', "/languages/frm": "French, Middle (ca. 1300-1600)",
'/languages/esp': 'Esperanto', "/languages/esp": "Esperanto",
'/languages/hau': 'Hausa', "/languages/hau": "Hausa",
'/languages/tag': 'Tagalog', "/languages/tag": "Tagalog",
'/languages/tuk': 'Turkmen', "/languages/tuk": "Turkmen",
'/languages/enm': 'English, Middle (1100-1500)', "/languages/enm": "English, Middle (1100-1500)",
'/languages/map': 'Austronesian (Other)', "/languages/map": "Austronesian (Other)",
'/languages/pli': 'Pali', "/languages/pli": "Pali",
'/languages/fro': 'French, Old (ca. 842-1300)', "/languages/fro": "French, Old (ca. 842-1300)",
'/languages/nic': 'Niger-Kordofanian (Other)', "/languages/nic": "Niger-Kordofanian (Other)",
'/languages/tir': 'Tigrinya', "/languages/tir": "Tigrinya",
'/languages/wen': 'Sorbian (Other)', "/languages/wen": "Sorbian (Other)",
'/languages/bho': 'Bhojpuri', "/languages/bho": "Bhojpuri",
'/languages/roa': 'Romance (Other)', "/languages/roa": "Romance (Other)",
'/languages/tut': 'Altaic (Other)', "/languages/tut": "Altaic (Other)",
'/languages/bra': 'Braj', "/languages/bra": "Braj",
'/languages/sun': 'Sundanese', "/languages/sun": "Sundanese",
'/languages/fiu': 'Finno-Ugrian (Other)', "/languages/fiu": "Finno-Ugrian (Other)",
'/languages/far': 'Faroese', "/languages/far": "Faroese",
'/languages/ban': 'Balinese', "/languages/ban": "Balinese",
'/languages/tar': 'Tatar', "/languages/tar": "Tatar",
'/languages/bak': 'Bashkir', "/languages/bak": "Bashkir",
'/languages/tat': 'Tatar', "/languages/tat": "Tatar",
'/languages/chu': 'Church Slavic', "/languages/chu": "Church Slavic",
'/languages/dra': 'Dravidian (Other)', "/languages/dra": "Dravidian (Other)",
'/languages/pra': 'Prakrit languages', "/languages/pra": "Prakrit languages",
'/languages/paa': 'Papuan (Other)', "/languages/paa": "Papuan (Other)",
'/languages/doi': 'Dogri', "/languages/doi": "Dogri",
'/languages/lah': 'Lahndā', "/languages/lah": "Lahndā",
'/languages/mni': 'Manipuri', "/languages/mni": "Manipuri",
'/languages/yor': 'Yoruba', "/languages/yor": "Yoruba",
'/languages/gmh': 'German, Middle High (ca. 1050-1500)', "/languages/gmh": "German, Middle High (ca. 1050-1500)",
'/languages/kas': 'Kashmiri', "/languages/kas": "Kashmiri",
'/languages/fri': 'Frisian', "/languages/fri": "Frisian",
'/languages/mla': 'Malagasy', "/languages/mla": "Malagasy",
'/languages/egy': 'Egyptian', "/languages/egy": "Egyptian",
'/languages/rom': 'Romani', "/languages/rom": "Romani",
'/languages/syr': 'Syriac, Modern', "/languages/syr": "Syriac, Modern",
'/languages/cau': 'Caucasian (Other)', "/languages/cau": "Caucasian (Other)",
'/languages/hbs': 'Serbo-Croatian', "/languages/hbs": "Serbo-Croatian",
'/languages/sai': 'South American Indian (Other)', "/languages/sai": "South American Indian (Other)",
'/languages/pro': 'Provençal (to 1500)', "/languages/pro": "Provençal (to 1500)",
'/languages/cpf': 'Creoles and Pidgins, French-based (Other)', "/languages/cpf": "Creoles and Pidgins, French-based (Other)",
'/languages/ang': 'English, Old (ca. 450-1100)', "/languages/ang": "English, Old (ca. 450-1100)",
'/languages/bal': 'Baluchi', "/languages/bal": "Baluchi",
'/languages/gla': 'Scottish Gaelic', "/languages/gla": "Scottish Gaelic",
'/languages/chv': 'Chuvash', "/languages/chv": "Chuvash",
'/languages/kin': 'Kinyarwanda', "/languages/kin": "Kinyarwanda",
'/languages/zul': 'Zulu', "/languages/zul": "Zulu",
'/languages/sla': 'Slavic (Other)', "/languages/sla": "Slavic (Other)",
'/languages/som': 'Somali', "/languages/som": "Somali",
'/languages/mlt': 'Maltese', "/languages/mlt": "Maltese",
'/languages/uig': 'Uighur', "/languages/uig": "Uighur",
'/languages/mlg': 'Malagasy', "/languages/mlg": "Malagasy",
'/languages/sho': 'Shona', "/languages/sho": "Shona",
'/languages/lan': 'Occitan (post 1500)', "/languages/lan": "Occitan (post 1500)",
'/languages/bre': 'Breton', "/languages/bre": "Breton",
'/languages/sco': 'Scots', "/languages/sco": "Scots",
'/languages/sso': 'Sotho', "/languages/sso": "Sotho",
'/languages/myn': 'Mayan languages', "/languages/myn": "Mayan languages",
'/languages/xho': 'Xhosa', "/languages/xho": "Xhosa",
'/languages/gem': 'Germanic (Other)', "/languages/gem": "Germanic (Other)",
'/languages/esk': 'Eskimo languages', "/languages/esk": "Eskimo languages",
'/languages/akk': 'Akkadian', "/languages/akk": "Akkadian",
'/languages/div': 'Maldivian', "/languages/div": "Maldivian",
'/languages/sah': 'Yakut', "/languages/sah": "Yakut",
'/languages/tsw': 'Tswana', "/languages/tsw": "Tswana",
'/languages/nso': 'Northern Sotho', "/languages/nso": "Northern Sotho",
'/languages/pap': 'Papiamento', "/languages/pap": "Papiamento",
'/languages/bnt': 'Bantu (Other)', "/languages/bnt": "Bantu (Other)",
'/languages/oss': 'Ossetic', "/languages/oss": "Ossetic",
'/languages/cre': 'Cree', "/languages/cre": "Cree",
'/languages/ibo': 'Igbo', "/languages/ibo": "Igbo",
'/languages/fao': 'Faroese', "/languages/fao": "Faroese",
'/languages/nai': 'North American Indian (Other)', "/languages/nai": "North American Indian (Other)",
'/languages/mag': 'Magahi', "/languages/mag": "Magahi",
'/languages/arc': 'Aramaic', "/languages/arc": "Aramaic",
'/languages/epo': 'Esperanto', "/languages/epo": "Esperanto",
'/languages/kha': 'Khasi', "/languages/kha": "Khasi",
'/languages/oji': 'Ojibwa', "/languages/oji": "Ojibwa",
'/languages/que': 'Quechua', "/languages/que": "Quechua",
'/languages/lug': 'Ganda', "/languages/lug": "Ganda",
'/languages/mwr': 'Marwari', "/languages/mwr": "Marwari",
'/languages/awa': 'Awadhi ', "/languages/awa": "Awadhi ",
'/languages/cor': 'Cornish', "/languages/cor": "Cornish",
'/languages/lad': 'Ladino', "/languages/lad": "Ladino",
'/languages/dzo': 'Dzongkha', "/languages/dzo": "Dzongkha",
'/languages/cop': 'Coptic', "/languages/cop": "Coptic",
'/languages/nah': 'Nahuatl', "/languages/nah": "Nahuatl",
'/languages/cai': 'Central American Indian (Other)', "/languages/cai": "Central American Indian (Other)",
'/languages/phi': 'Philippine (Other)', "/languages/phi": "Philippine (Other)",
'/languages/moh': 'Mohawk', "/languages/moh": "Mohawk",
'/languages/crp': 'Creoles and Pidgins (Other)', "/languages/crp": "Creoles and Pidgins (Other)",
'/languages/nya': 'Nyanja', "/languages/nya": "Nyanja",
'/languages/wol': 'Wolof ', "/languages/wol": "Wolof ",
'/languages/haw': 'Hawaiian', "/languages/haw": "Hawaiian",
'/languages/eth': 'Ethiopic', "/languages/eth": "Ethiopic",
'/languages/mis': 'Miscellaneous languages', "/languages/mis": "Miscellaneous languages",
'/languages/mkh': 'Mon-Khmer (Other)', "/languages/mkh": "Mon-Khmer (Other)",
'/languages/alg': 'Algonquian (Other)', "/languages/alg": "Algonquian (Other)",
'/languages/nde': 'Ndebele (Zimbabwe)', "/languages/nde": "Ndebele (Zimbabwe)",
'/languages/ssa': 'Nilo-Saharan (Other)', "/languages/ssa": "Nilo-Saharan (Other)",
'/languages/chm': 'Mari', "/languages/chm": "Mari",
'/languages/che': 'Chechen', "/languages/che": "Chechen",
'/languages/gez': 'Ethiopic', "/languages/gez": "Ethiopic",
'/languages/ven': 'Venda', "/languages/ven": "Venda",
'/languages/cam': 'Khmer', "/languages/cam": "Khmer",
'/languages/fur': 'Friulian', "/languages/fur": "Friulian",
'/languages/ful': 'Fula', "/languages/ful": "Fula",
'/languages/gal': 'Oromo', "/languages/gal": "Oromo",
'/languages/jrb': 'Judeo-Arabic', "/languages/jrb": "Judeo-Arabic",
'/languages/bua': 'Buriat', "/languages/bua": "Buriat",
'/languages/ady': 'Adygei', "/languages/ady": "Adygei",
'/languages/bem': 'Bemba', "/languages/bem": "Bemba",
'/languages/kar': 'Karen languages', "/languages/kar": "Karen languages",
'/languages/sna': 'Shona', "/languages/sna": "Shona",
'/languages/twi': 'Twi', "/languages/twi": "Twi",
'/languages/btk': 'Batak', "/languages/btk": "Batak",
'/languages/kaa': 'Kara-Kalpak', "/languages/kaa": "Kara-Kalpak",
'/languages/kom': 'Komi', "/languages/kom": "Komi",
'/languages/sot': 'Sotho', "/languages/sot": "Sotho",
'/languages/tso': 'Tsonga', "/languages/tso": "Tsonga",
'/languages/cpe': 'Creoles and Pidgins, English-based (Other)', "/languages/cpe": "Creoles and Pidgins, English-based (Other)",
'/languages/gua': 'Guarani', "/languages/gua": "Guarani",
'/languages/mao': 'Maori', "/languages/mao": "Maori",
'/languages/mic': 'Micmac', "/languages/mic": "Micmac",
'/languages/swz': 'Swazi', "/languages/swz": "Swazi",
'/languages/taj': 'Tajik', "/languages/taj": "Tajik",
'/languages/smo': 'Samoan', "/languages/smo": "Samoan",
'/languages/ace': 'Achinese', "/languages/ace": "Achinese",
'/languages/afa': 'Afroasiatic (Other)', "/languages/afa": "Afroasiatic (Other)",
'/languages/lap': 'Sami', "/languages/lap": "Sami",
'/languages/min': 'Minangkabau', "/languages/min": "Minangkabau",
'/languages/oci': 'Occitan (post 1500)', "/languages/oci": "Occitan (post 1500)",
'/languages/tsn': 'Tswana', "/languages/tsn": "Tswana",
'/languages/pal': 'Pahlavi', "/languages/pal": "Pahlavi",
'/languages/sux': 'Sumerian', "/languages/sux": "Sumerian",
'/languages/ewe': 'Ewe', "/languages/ewe": "Ewe",
'/languages/him': 'Himachali', "/languages/him": "Himachali",
'/languages/kaw': 'Kawi', "/languages/kaw": "Kawi",
'/languages/lus': 'Lushai', "/languages/lus": "Lushai",
'/languages/ceb': 'Cebuano', "/languages/ceb": "Cebuano",
'/languages/chr': 'Cherokee', "/languages/chr": "Cherokee",
'/languages/fil': 'Filipino', "/languages/fil": "Filipino",
'/languages/ndo': 'Ndonga', "/languages/ndo": "Ndonga",
'/languages/ilo': 'Iloko', "/languages/ilo": "Iloko",
'/languages/kbd': 'Kabardian', "/languages/kbd": "Kabardian",
'/languages/orm': 'Oromo', "/languages/orm": "Oromo",
'/languages/dum': 'Dutch, Middle (ca. 1050-1350)', "/languages/dum": "Dutch, Middle (ca. 1050-1350)",
'/languages/bam': 'Bambara', "/languages/bam": "Bambara",
'/languages/goh': 'Old High German', "/languages/goh": "Old High German",
'/languages/got': 'Gothic', "/languages/got": "Gothic",
'/languages/kon': 'Kongo', "/languages/kon": "Kongo",
'/languages/mun': 'Munda (Other)', "/languages/mun": "Munda (Other)",
'/languages/kru': 'Kurukh', "/languages/kru": "Kurukh",
'/languages/pam': 'Pampanga', "/languages/pam": "Pampanga",
'/languages/grn': 'Guarani', "/languages/grn": "Guarani",
'/languages/gaa': '', "/languages/gaa": "",
'/languages/fry': 'Frisian', "/languages/fry": "Frisian",
'/languages/iba': 'Iban', "/languages/iba": "Iban",
'/languages/mak': 'Makasar', "/languages/mak": "Makasar",
'/languages/kik': 'Kikuyu', "/languages/kik": "Kikuyu",
'/languages/cho': 'Choctaw', "/languages/cho": "Choctaw",
'/languages/cpp': 'Creoles and Pidgins, Portuguese-based (Other)', "/languages/cpp": "Creoles and Pidgins, Portuguese-based (Other)",
'/languages/dak': 'Dakota', "/languages/dak": "Dakota",
'/languages/udm': 'Udmurt ', "/languages/udm": "Udmurt ",
'/languages/hat': 'Haitian French Creole', "/languages/hat": "Haitian French Creole",
'/languages/mus': 'Creek', "/languages/mus": "Creek",
'/languages/ber': 'Berber (Other)', "/languages/ber": "Berber (Other)",
'/languages/hil': 'Hiligaynon', "/languages/hil": "Hiligaynon",
'/languages/iro': 'Iroquoian (Other)', "/languages/iro": "Iroquoian (Other)",
'/languages/kua': 'Kuanyama', "/languages/kua": "Kuanyama",
'/languages/mno': 'Manobo languages', "/languages/mno": "Manobo languages",
'/languages/run': 'Rundi', "/languages/run": "Rundi",
'/languages/sat': 'Santali', "/languages/sat": "Santali",
'/languages/shn': 'Shan', "/languages/shn": "Shan",
'/languages/tyv': 'Tuvinian', "/languages/tyv": "Tuvinian",
'/languages/chg': 'Chagatai', "/languages/chg": "Chagatai",
'/languages/syc': 'Syriac', "/languages/syc": "Syriac",
'/languages/ath': 'Athapascan (Other)', "/languages/ath": "Athapascan (Other)",
'/languages/aym': 'Aymara', "/languages/aym": "Aymara",
'/languages/bug': 'Bugis', "/languages/bug": "Bugis",
'/languages/cel': 'Celtic (Other)', "/languages/cel": "Celtic (Other)",
'/languages/int': 'Interlingua (International Auxiliary Language Association)', "/languages/int": "Interlingua (International Auxiliary Language Association)",
'/languages/xal': 'Oirat', "/languages/xal": "Oirat",
'/languages/ava': 'Avaric', "/languages/ava": "Avaric",
'/languages/son': 'Songhai', "/languages/son": "Songhai",
'/languages/tah': 'Tahitian', "/languages/tah": "Tahitian",
'/languages/tet': 'Tetum', "/languages/tet": "Tetum",
'/languages/ira': 'Iranian (Other)', "/languages/ira": "Iranian (Other)",
'/languages/kac': 'Kachin', "/languages/kac": "Kachin",
'/languages/nob': 'Norwegian (Bokmål)', "/languages/nob": "Norwegian (Bokmål)",
'/languages/vai': 'Vai', "/languages/vai": "Vai",
'/languages/bik': 'Bikol', "/languages/bik": "Bikol",
'/languages/mos': 'Mooré', "/languages/mos": "Mooré",
'/languages/tig': 'Tigré', "/languages/tig": "Tigré",
'/languages/fat': 'Fanti', "/languages/fat": "Fanti",
'/languages/her': 'Herero', "/languages/her": "Herero",
'/languages/kal': 'Kalâtdlisut', "/languages/kal": "Kalâtdlisut",
'/languages/mad': 'Madurese', "/languages/mad": "Madurese",
'/languages/yue': 'Cantonese', "/languages/yue": "Cantonese",
'/languages/chn': 'Chinook jargon', "/languages/chn": "Chinook jargon",
'/languages/hmn': 'Hmong', "/languages/hmn": "Hmong",
'/languages/lin': 'Lingala', "/languages/lin": "Lingala",
'/languages/man': 'Mandingo', "/languages/man": "Mandingo",
'/languages/nds': 'Low German', "/languages/nds": "Low German",
'/languages/bas': 'Basa', "/languages/bas": "Basa",
'/languages/gay': 'Gayo', "/languages/gay": "Gayo",
'/languages/gsw': 'gsw', "/languages/gsw": "gsw",
'/languages/ine': 'Indo-European (Other)', "/languages/ine": "Indo-European (Other)",
'/languages/kro': 'Kru (Other)', "/languages/kro": "Kru (Other)",
'/languages/kum': 'Kumyk', "/languages/kum": "Kumyk",
'/languages/tsi': 'Tsimshian', "/languages/tsi": "Tsimshian",
'/languages/zap': 'Zapotec', "/languages/zap": "Zapotec",
'/languages/ach': 'Acoli', "/languages/ach": "Acoli",
'/languages/ada': 'Adangme', "/languages/ada": "Adangme",
'/languages/aka': 'Akan', "/languages/aka": "Akan",
'/languages/khi': 'Khoisan (Other)', "/languages/khi": "Khoisan (Other)",
'/languages/srd': 'Sardinian', "/languages/srd": "Sardinian",
'/languages/arn': 'Mapuche', "/languages/arn": "Mapuche",
'/languages/dyu': 'Dyula', "/languages/dyu": "Dyula",
'/languages/loz': 'Lozi', "/languages/loz": "Lozi",
'/languages/ltz': 'Luxembourgish', "/languages/ltz": "Luxembourgish",
'/languages/sag': 'Sango (Ubangi Creole)', "/languages/sag": "Sango (Ubangi Creole)",
'/languages/lez': 'Lezgian', "/languages/lez": "Lezgian",
'/languages/luo': 'Luo (Kenya and Tanzania)', "/languages/luo": "Luo (Kenya and Tanzania)",
'/languages/ssw': 'Swazi ', "/languages/ssw": "Swazi ",
'/languages/krc': 'Karachay-Balkar', "/languages/krc": "Karachay-Balkar",
'/languages/nyn': 'Nyankole', "/languages/nyn": "Nyankole",
'/languages/sal': 'Salishan languages', "/languages/sal": "Salishan languages",
'/languages/jpr': 'Judeo-Persian', "/languages/jpr": "Judeo-Persian",
'/languages/pau': 'Palauan', "/languages/pau": "Palauan",
'/languages/smi': 'Sami', "/languages/smi": "Sami",
'/languages/aar': 'Afar', "/languages/aar": "Afar",
'/languages/abk': 'Abkhaz', "/languages/abk": "Abkhaz",
'/languages/gon': 'Gondi', "/languages/gon": "Gondi",
'/languages/nzi': 'Nzima', "/languages/nzi": "Nzima",
'/languages/sam': 'Samaritan Aramaic', "/languages/sam": "Samaritan Aramaic",
'/languages/sao': 'Samoan', "/languages/sao": "Samoan",
'/languages/srr': 'Serer', "/languages/srr": "Serer",
'/languages/apa': 'Apache languages', "/languages/apa": "Apache languages",
'/languages/crh': 'Crimean Tatar', "/languages/crh": "Crimean Tatar",
'/languages/efi': 'Efik', "/languages/efi": "Efik",
'/languages/iku': 'Inuktitut', "/languages/iku": "Inuktitut",
'/languages/nav': 'Navajo', "/languages/nav": "Navajo",
'/languages/pon': 'Ponape', "/languages/pon": "Ponape",
'/languages/tmh': 'Tamashek', "/languages/tmh": "Tamashek",
'/languages/aus': 'Australian languages', "/languages/aus": "Australian languages",
'/languages/oto': 'Otomian languages', "/languages/oto": "Otomian languages",
'/languages/war': 'Waray', "/languages/war": "Waray",
'/languages/ypk': 'Yupik languages', "/languages/ypk": "Yupik languages",
'/languages/ave': 'Avestan', "/languages/ave": "Avestan",
'/languages/cus': 'Cushitic (Other)', "/languages/cus": "Cushitic (Other)",
'/languages/del': 'Delaware', "/languages/del": "Delaware",
'/languages/fon': 'Fon', "/languages/fon": "Fon",
'/languages/ina': 'Interlingua (International Auxiliary Language Association)', "/languages/ina": "Interlingua (International Auxiliary Language Association)",
'/languages/myv': 'Erzya', "/languages/myv": "Erzya",
'/languages/pag': 'Pangasinan', "/languages/pag": "Pangasinan",
'/languages/peo': 'Old Persian (ca. 600-400 B.C.)', "/languages/peo": "Old Persian (ca. 600-400 B.C.)",
'/languages/vls': 'Flemish', "/languages/vls": "Flemish",
'/languages/bai': 'Bamileke languages', "/languages/bai": "Bamileke languages",
'/languages/bla': 'Siksika', "/languages/bla": "Siksika",
'/languages/day': 'Dayak', "/languages/day": "Dayak",
'/languages/men': 'Mende', "/languages/men": "Mende",
'/languages/tai': 'Tai', "/languages/tai": "Tai",
'/languages/ton': 'Tongan', "/languages/ton": "Tongan",
'/languages/uga': 'Ugaritic', "/languages/uga": "Ugaritic",
'/languages/yao': 'Yao (Africa)', "/languages/yao": "Yao (Africa)",
'/languages/zza': 'Zaza', "/languages/zza": "Zaza",
'/languages/bin': 'Edo', "/languages/bin": "Edo",
'/languages/frs': 'East Frisian', "/languages/frs": "East Frisian",
'/languages/inh': 'Ingush', "/languages/inh": "Ingush",
'/languages/mah': 'Marshallese', "/languages/mah": "Marshallese",
'/languages/sem': 'Semitic (Other)', "/languages/sem": "Semitic (Other)",
'/languages/art': 'Artificial (Other)', "/languages/art": "Artificial (Other)",
'/languages/chy': 'Cheyenne', "/languages/chy": "Cheyenne",
'/languages/cmc': 'Chamic languages', "/languages/cmc": "Chamic languages",
'/languages/dar': 'Dargwa', "/languages/dar": "Dargwa",
'/languages/dua': 'Duala', "/languages/dua": "Duala",
'/languages/elx': 'Elamite', "/languages/elx": "Elamite",
'/languages/fan': 'Fang', "/languages/fan": "Fang",
'/languages/fij': 'Fijian', "/languages/fij": "Fijian",
'/languages/gil': 'Gilbertese', "/languages/gil": "Gilbertese",
'/languages/ijo': 'Ijo', "/languages/ijo": "Ijo",
'/languages/kam': 'Kamba', "/languages/kam": "Kamba",
'/languages/nog': 'Nogai', "/languages/nog": "Nogai",
'/languages/non': 'Old Norse', "/languages/non": "Old Norse",
'/languages/tem': 'Temne', "/languages/tem": "Temne",
'/languages/arg': 'Aragonese', "/languages/arg": "Aragonese",
'/languages/arp': 'Arapaho', "/languages/arp": "Arapaho",
'/languages/arw': 'Arawak', "/languages/arw": "Arawak",
'/languages/din': 'Dinka', "/languages/din": "Dinka",
'/languages/grb': 'Grebo', "/languages/grb": "Grebo",
'/languages/kos': 'Kusaie', "/languages/kos": "Kusaie",
'/languages/lub': 'Luba-Katanga', "/languages/lub": "Luba-Katanga",
'/languages/mnc': 'Manchu', "/languages/mnc": "Manchu",
'/languages/nyo': 'Nyoro', "/languages/nyo": "Nyoro",
'/languages/rar': 'Rarotongan', "/languages/rar": "Rarotongan",
'/languages/sel': 'Selkup', "/languages/sel": "Selkup",
'/languages/tkl': 'Tokelauan', "/languages/tkl": "Tokelauan",
'/languages/tog': 'Tonga (Nyasa)', "/languages/tog": "Tonga (Nyasa)",
'/languages/tum': 'Tumbuka', "/languages/tum": "Tumbuka",
'/languages/alt': 'Altai', "/languages/alt": "Altai",
'/languages/ase': 'American Sign Language', "/languages/ase": "American Sign Language",
'/languages/ast': 'Asturian', "/languages/ast": "Asturian",
'/languages/chk': 'Chuukese', "/languages/chk": "Chuukese",
'/languages/cos': 'Corsican', "/languages/cos": "Corsican",
'/languages/ewo': 'Ewondo', "/languages/ewo": "Ewondo",
'/languages/gor': 'Gorontalo', "/languages/gor": "Gorontalo",
'/languages/hmo': 'Hiri Motu', "/languages/hmo": "Hiri Motu",
'/languages/lol': 'Mongo-Nkundu', "/languages/lol": "Mongo-Nkundu",
'/languages/lun': 'Lunda', "/languages/lun": "Lunda",
'/languages/mas': 'Masai', "/languages/mas": "Masai",
'/languages/niu': 'Niuean', "/languages/niu": "Niuean",
'/languages/rup': 'Aromanian', "/languages/rup": "Aromanian",
'/languages/sas': 'Sasak', "/languages/sas": "Sasak",
'/languages/sio': 'Siouan (Other)', "/languages/sio": "Siouan (Other)",
'/languages/sus': 'Susu', "/languages/sus": "Susu",
'/languages/zun': 'Zuni', "/languages/zun": "Zuni",
'/languages/bat': 'Baltic (Other)', "/languages/bat": "Baltic (Other)",
'/languages/car': 'Carib', "/languages/car": "Carib",
'/languages/cha': 'Chamorro', "/languages/cha": "Chamorro",
'/languages/kab': 'Kabyle', "/languages/kab": "Kabyle",
'/languages/kau': 'Kanuri', "/languages/kau": "Kanuri",
'/languages/kho': 'Khotanese', "/languages/kho": "Khotanese",
'/languages/lua': 'Luba-Lulua', "/languages/lua": "Luba-Lulua",
'/languages/mdf': 'Moksha', "/languages/mdf": "Moksha",
'/languages/nbl': 'Ndebele (South Africa)', "/languages/nbl": "Ndebele (South Africa)",
'/languages/umb': 'Umbundu', "/languages/umb": "Umbundu",
'/languages/wak': 'Wakashan languages', "/languages/wak": "Wakashan languages",
'/languages/wal': 'Wolayta', "/languages/wal": "Wolayta",
'/languages/ale': 'Aleut', "/languages/ale": "Aleut",
'/languages/bis': 'Bislama', "/languages/bis": "Bislama",
'/languages/gba': 'Gbaya', "/languages/gba": "Gbaya",
'/languages/glv': 'Manx', "/languages/glv": "Manx",
'/languages/gul': 'Gullah', "/languages/gul": "Gullah",
'/languages/ipk': 'Inupiaq', "/languages/ipk": "Inupiaq",
'/languages/krl': 'Karelian', "/languages/krl": "Karelian",
'/languages/lam': 'Lamba (Zambia and Congo)', "/languages/lam": "Lamba (Zambia and Congo)",
'/languages/sad': 'Sandawe', "/languages/sad": "Sandawe",
'/languages/sid': 'Sidamo', "/languages/sid": "Sidamo",
'/languages/snk': 'Soninke', "/languages/snk": "Soninke",
'/languages/srn': 'Sranan', "/languages/srn": "Sranan",
'/languages/suk': 'Sukuma', "/languages/suk": "Sukuma",
'/languages/ter': 'Terena', "/languages/ter": "Terena",
'/languages/tiv': 'Tiv', "/languages/tiv": "Tiv",
'/languages/tli': 'Tlingit', "/languages/tli": "Tlingit",
'/languages/tpi': 'Tok Pisin', "/languages/tpi": "Tok Pisin",
'/languages/tvl': 'Tuvaluan', "/languages/tvl": "Tuvaluan",
'/languages/yap': 'Yapese', "/languages/yap": "Yapese",
'/languages/eka': 'Ekajuk', "/languages/eka": "Ekajuk",
'/languages/hsb': 'Upper Sorbian', "/languages/hsb": "Upper Sorbian",
'/languages/ido': 'Ido', "/languages/ido": "Ido",
'/languages/kmb': 'Kimbundu', "/languages/kmb": "Kimbundu",
'/languages/kpe': 'Kpelle', "/languages/kpe": "Kpelle",
'/languages/mwl': 'Mirandese', "/languages/mwl": "Mirandese",
'/languages/nno': 'Nynorsk', "/languages/nno": "Nynorsk",
'/languages/nub': 'Nubian languages', "/languages/nub": "Nubian languages",
'/languages/osa': 'Osage', "/languages/osa": "Osage",
'/languages/sme': 'Northern Sami', "/languages/sme": "Northern Sami",
'/languages/znd': 'Zande languages', "/languages/znd": "Zande languages",
} }

View file

@ -1,26 +1,28 @@
''' using a bookwyrm instance as a source of book data ''' """ using a bookwyrm instance as a source of book data """
from functools import reduce from functools import reduce
import operator import operator
from django.contrib.postgres.search import SearchRank, SearchVector from django.contrib.postgres.search import SearchRank, SearchVector
from django.db.models import Count, F, Q from django.db.models import Count, OuterRef, Subquery, F, Q
from bookwyrm import models from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector): class Connector(AbstractConnector):
''' instantiate a connector ''' """instantiate a connector"""
# pylint: disable=arguments-differ # pylint: disable=arguments-differ
def search(self, query, min_confidence=0.1, raw=False): def search(self, query, min_confidence=0.1, raw=False, filters=None):
''' search your local database ''' """search your local database"""
filters = filters or []
if not query: if not query:
return [] return []
# first, try searching unqiue identifiers # first, try searching unqiue identifiers
results = search_identifiers(query) results = search_identifiers(query, *filters)
if not results: if not results:
# then try searching title/author # then try searching title/author
results = search_title_author(query, min_confidence) results = search_title_author(query, min_confidence, *filters)
search_results = [] search_results = []
for result in results: for result in results:
if raw: if raw:
@ -33,19 +35,58 @@ class Connector(AbstractConnector):
search_results.sort(key=lambda r: r.confidence, reverse=True) search_results.sort(key=lambda r: r.confidence, reverse=True)
return search_results return search_results
def isbn_search(self, query, raw=False):
"""search your local database"""
if not query:
return []
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
results = models.Edition.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters))
).distinct()
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
default_editions = models.Edition.objects.filter(
parent_work=OuterRef("parent_work")
).order_by("-edition_rank")
results = (
results.annotate(
default_id=Subquery(default_editions.values("id")[:1])
).filter(default_id=F("id"))
or results
)
search_results = []
for result in results:
if raw:
search_results.append(result)
else:
search_results.append(self.format_search_result(result))
if len(search_results) >= 10:
break
return search_results
def format_search_result(self, search_result): def format_search_result(self, search_result):
cover = None
if search_result.cover:
cover = "%s%s" % (self.covers_url, search_result.cover)
return SearchResult( return SearchResult(
title=search_result.title, title=search_result.title,
key=search_result.remote_id, key=search_result.remote_id,
author=search_result.author_text, author=search_result.author_text,
year=search_result.published_date.year if \ year=search_result.published_date.year
search_result.published_date else None, if search_result.published_date
else None,
connector=self, connector=self,
confidence=search_result.rank if \ cover=cover,
hasattr(search_result, 'rank') else 1, confidence=search_result.rank if hasattr(search_result, "rank") else 1,
) )
def format_isbn_search_result(self, search_result):
return self.format_search_result(search_result)
def is_work_data(self, data): def is_work_data(self, data):
pass pass
@ -59,56 +100,71 @@ class Connector(AbstractConnector):
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
return None return None
def parse_isbn_search_data(self, data):
"""it's already in the right format, don't even worry about it"""
return data
def parse_search_data(self, data): def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it ''' """it's already in the right format, don't even worry about it"""
return data return data
def expand_book_data(self, book): def expand_book_data(self, book):
pass pass
def search_identifiers(query): def search_identifiers(query, *filters):
''' tries remote_id, isbn; defined as dedupe fields on the model ''' """tries remote_id, isbn; defined as dedupe fields on the model"""
filters = [{f.name: query} for f in models.Edition._meta.get_fields() \ or_filters = [
if hasattr(f, 'deduplication_field') and f.deduplication_field] {f.name: query}
for f in models.Edition._meta.get_fields()
if hasattr(f, "deduplication_field") and f.deduplication_field
]
results = models.Edition.objects.filter( results = models.Edition.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters)) *filters, reduce(operator.or_, (Q(**f) for f in or_filters))
).distinct() ).distinct()
# when there are multiple editions of the same work, pick the default. # when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen. # it would be odd for this to happen.
return results.filter(parent_work__default_edition__id=F('id')) \ default_editions = models.Edition.objects.filter(
or results parent_work=OuterRef("parent_work")
).order_by("-edition_rank")
return (
results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
default_id=F("id")
)
or results
)
def search_title_author(query, min_confidence): def search_title_author(query, min_confidence, *filters):
''' searches for title and author ''' """searches for title and author"""
vector = SearchVector('title', weight='A') +\ vector = (
SearchVector('subtitle', weight='B') +\ SearchVector("title", weight="A")
SearchVector('authors__name', weight='C') +\ + SearchVector("subtitle", weight="B")
SearchVector('series', weight='D') + SearchVector("authors__name", weight="C")
+ SearchVector("series", weight="D")
)
results = models.Edition.objects.annotate( results = (
search=vector models.Edition.objects.annotate(search=vector)
).annotate( .annotate(rank=SearchRank(vector, query))
rank=SearchRank(vector, query) .filter(*filters, rank__gt=min_confidence)
).filter( .order_by("-rank")
rank__gt=min_confidence )
).order_by('-rank')
# when there are multiple editions of the same work, pick the closest # when there are multiple editions of the same work, pick the closest
editions_of_work = results.values( editions_of_work = (
'parent_work' results.values("parent_work")
).annotate( .annotate(Count("parent_work"))
Count('parent_work') .values_list("parent_work")
).values_list('parent_work') )
for work_id in set(editions_of_work): for work_id in set(editions_of_work):
editions = results.filter(parent_work=work_id) editions = results.filter(parent_work=work_id)
default = editions.filter(parent_work__default_edition=F('id')) default = editions.order_by("-edition_rank").first()
default_rank = default.first().rank if default.exists() else 0 default_rank = default.rank if default else 0
# if mutliple books have the top rank, pick the default edition # if mutliple books have the top rank, pick the default edition
if default_rank == editions.first().rank: if default_rank == editions.first().rank:
yield default.first() yield default
else: else:
yield editions.first() yield editions.first()

View file

@ -1,3 +1,3 @@
''' settings book data connectors ''' """ settings book data connectors """
CONNECTORS = ['openlibrary', 'self_connector', 'bookwyrm_connector'] CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"]

View file

@ -1,8 +1,10 @@
''' customize the info available in context for rendering templates ''' """ customize the info available in context for rendering templates """
from bookwyrm import models from bookwyrm import models
def site_settings(request):# pylint: disable=unused-argument
''' include the custom info about the site ''' def site_settings(request): # pylint: disable=unused-argument
"""include the custom info about the site"""
return { return {
'site': models.SiteSettings.objects.get() "site": models.SiteSettings.objects.get(),
"active_announcements": models.Announcement.active_announcements(),
} }

View file

@ -1,25 +1,66 @@
''' send emails ''' """ send emails """
from django.core.mail import send_mail from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
from bookwyrm import models from bookwyrm import models, settings
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.settings import DOMAIN
def email_data():
"""fields every email needs"""
site = models.SiteSettings.objects.get()
if site.logo_small:
logo_path = "/images/{}".format(site.logo_small.url)
else:
logo_path = "/static/images/logo-small.png"
return {
"site_name": site.name,
"logo": logo_path,
"domain": DOMAIN,
"user": None,
}
def invite_email(invite_request):
"""send out an invite code"""
data = email_data()
data["invite_link"] = invite_request.invite.link
send_email.delay(invite_request.email, *format_email("invite", data))
def password_reset_email(reset_code): def password_reset_email(reset_code):
''' generate a password reset email ''' """generate a password reset email"""
site = models.SiteSettings.get() data = email_data()
send_email.delay( data["reset_link"] = reset_code.link
reset_code.user.email, data["user"] = reset_code.user.display_name
'Reset your password on %s' % site.name, send_email.delay(reset_code.user.email, *format_email("password_reset", data))
'Your password reset link: %s' % reset_code.link
def format_email(email_name, data):
"""render the email templates"""
subject = (
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
) )
html_content = (
get_template("email/{}/html_content.html".format(email_name))
.render(data)
.strip()
)
text_content = (
get_template("email/{}/text_content.html".format(email_name))
.render(data)
.strip()
)
return (subject, html_content, text_content)
@app.task @app.task
def send_email(recipient, subject, message): def send_email(recipient, subject, html_content, text_content):
''' use a task to send the email ''' """use a task to send the email"""
send_mail( email = EmailMultiAlternatives(
subject, subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient]
message,
None, # sender will be the config default
[recipient],
fail_silently=False
) )
email.attach_alternative(html_content, "text/html")
email.send()

View file

@ -1,125 +1,172 @@
''' using django model forms ''' """ using django model forms """
import datetime import datetime
from collections import defaultdict from collections import defaultdict
from django import forms from django import forms
from django.forms import ModelForm, PasswordInput, widgets from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
from django.forms.widgets import Textarea from django.forms.widgets import Textarea
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import models from bookwyrm import models
class CustomForm(ModelForm): class CustomForm(ModelForm):
''' add css classes to the forms ''' """add css classes to the forms"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
css_classes = defaultdict(lambda: '') css_classes = defaultdict(lambda: "")
css_classes['text'] = 'input' css_classes["text"] = "input"
css_classes['password'] = 'input' css_classes["password"] = "input"
css_classes['email'] = 'input' css_classes["email"] = "input"
css_classes['number'] = 'input' css_classes["number"] = "input"
css_classes['checkbox'] = 'checkbox' css_classes["checkbox"] = "checkbox"
css_classes['textarea'] = 'textarea' css_classes["textarea"] = "textarea"
super(CustomForm, self).__init__(*args, **kwargs) super(CustomForm, self).__init__(*args, **kwargs)
for visible in self.visible_fields(): for visible in self.visible_fields():
if hasattr(visible.field.widget, 'input_type'): if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea): if isinstance(visible.field.widget, Textarea):
input_type = 'textarea' input_type = "textarea"
visible.field.widget.attrs['cols'] = None visible.field.widget.attrs["cols"] = None
visible.field.widget.attrs['rows'] = None visible.field.widget.attrs["rows"] = None
visible.field.widget.attrs['class'] = css_classes[input_type] visible.field.widget.attrs["class"] = css_classes[input_type]
# pylint: disable=missing-class-docstring # pylint: disable=missing-class-docstring
class LoginForm(CustomForm): class LoginForm(CustomForm):
class Meta: class Meta:
model = models.User model = models.User
fields = ['localname', 'password'] fields = ["localname", "password"]
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
widgets = { widgets = {
'password': PasswordInput(), "password": PasswordInput(),
} }
class RegisterForm(CustomForm): class RegisterForm(CustomForm):
class Meta: class Meta:
model = models.User model = models.User
fields = ['localname', 'email', 'password'] fields = ["localname", "email", "password"]
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
widgets = { widgets = {"password": PasswordInput()}
'password': PasswordInput()
}
class RatingForm(CustomForm): class RatingForm(CustomForm):
class Meta: class Meta:
model = models.Review model = models.ReviewRating
fields = ['user', 'book', 'content', 'rating', 'privacy'] fields = ["user", "book", "rating", "privacy"]
class ReviewForm(CustomForm): class ReviewForm(CustomForm):
class Meta: class Meta:
model = models.Review model = models.Review
fields = [ fields = [
'user', 'book', "user",
'name', 'content', 'rating', "book",
'content_warning', 'sensitive', "name",
'privacy'] "content",
"rating",
"content_warning",
"sensitive",
"privacy",
]
class CommentForm(CustomForm): class CommentForm(CustomForm):
class Meta: class Meta:
model = models.Comment model = models.Comment
fields = [ fields = [
'user', 'book', 'content', "user",
'content_warning', 'sensitive', "book",
'privacy'] "content",
"content_warning",
"sensitive",
"privacy",
"progress",
"progress_mode",
]
class QuotationForm(CustomForm): class QuotationForm(CustomForm):
class Meta: class Meta:
model = models.Quotation model = models.Quotation
fields = [ fields = [
'user', 'book', 'quote', 'content', "user",
'content_warning', 'sensitive', 'privacy'] "book",
"quote",
"content",
"content_warning",
"sensitive",
"privacy",
]
class ReplyForm(CustomForm): class ReplyForm(CustomForm):
class Meta: class Meta:
model = models.Status model = models.Status
fields = [ fields = [
'user', 'content', 'content_warning', 'sensitive', "user",
'reply_parent', 'privacy'] "content",
"content_warning",
"sensitive",
"reply_parent",
"privacy",
]
class StatusForm(CustomForm): class StatusForm(CustomForm):
class Meta: class Meta:
model = models.Status model = models.Status
fields = [ fields = ["user", "content", "content_warning", "sensitive", "privacy"]
'user', 'content', 'content_warning', 'sensitive', 'privacy']
class EditUserForm(CustomForm): class EditUserForm(CustomForm):
class Meta: class Meta:
model = models.User model = models.User
fields = [ fields = [
'avatar', 'name', 'email', 'summary', 'manually_approves_followers', 'default_post_privacy' "avatar",
"name",
"email",
"summary",
"show_goal",
"manually_approves_followers",
"default_post_privacy",
"discoverable",
"preferred_timezone",
] ]
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
class TagForm(CustomForm): class LimitedEditUserForm(CustomForm):
class Meta: class Meta:
model = models.Tag model = models.User
fields = ['name'] fields = [
"avatar",
"name",
"summary",
"manually_approves_followers",
"discoverable",
]
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
labels = {'name': 'Add a tag'}
class DeleteUserForm(CustomForm):
class Meta:
model = models.User
fields = ["password"]
class UserGroupForm(CustomForm):
class Meta:
model = models.User
fields = ["groups"]
class CoverForm(CustomForm): class CoverForm(CustomForm):
class Meta: class Meta:
model = models.Book model = models.Book
fields = ['cover'] fields = ["cover"]
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
@ -127,79 +174,100 @@ class EditionForm(CustomForm):
class Meta: class Meta:
model = models.Edition model = models.Edition
exclude = [ exclude = [
'remote_id', "remote_id",
'origin_id', "origin_id",
'created_date', "created_date",
'updated_date', "updated_date",
'edition_rank', "edition_rank",
"authors",
'authors',# TODO "parent_work",
'parent_work', "shelves",
'shelves', "subjects", # TODO
"subject_places", # TODO
'subjects',# TODO "connector",
'subject_places',# TODO
'connector',
] ]
class AuthorForm(CustomForm): class AuthorForm(CustomForm):
class Meta: class Meta:
model = models.Author model = models.Author
exclude = [ exclude = [
'remote_id', "remote_id",
'origin_id', "origin_id",
'created_date', "created_date",
'updated_date', "updated_date",
] ]
class ImportForm(forms.Form): class ImportForm(forms.Form):
csv_file = forms.FileField() csv_file = forms.FileField()
class ExpiryWidget(widgets.Select): class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
''' human-readable exiration time buckets ''' """human-readable exiration time buckets"""
selected_string = super().value_from_datadict(data, files, name) selected_string = super().value_from_datadict(data, files, name)
if selected_string == 'day': if selected_string == "day":
interval = datetime.timedelta(days=1) interval = datetime.timedelta(days=1)
elif selected_string == 'week': elif selected_string == "week":
interval = datetime.timedelta(days=7) interval = datetime.timedelta(days=7)
elif selected_string == 'month': elif selected_string == "month":
interval = datetime.timedelta(days=31) # Close enough? interval = datetime.timedelta(days=31) # Close enough?
elif selected_string == 'forever': elif selected_string == "forever":
return None return None
else: else:
return selected_string # "This will raise return selected_string # "This will raise
return timezone.now() + interval return timezone.now() + interval
class InviteRequestForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
cleaned_data = super().clean()
email = cleaned_data.get("email")
if email and models.User.objects.filter(email=email).exists():
self.add_error("email", _("A user with this email already exists."))
class Meta:
model = models.InviteRequest
fields = ["email"]
class CreateInviteForm(CustomForm): class CreateInviteForm(CustomForm):
class Meta: class Meta:
model = models.SiteInvite model = models.SiteInvite
exclude = ['code', 'user', 'times_used'] exclude = ["code", "user", "times_used", "invitees"]
widgets = { widgets = {
'expiry': ExpiryWidget(choices=[ "expiry": ExpiryWidget(
('day', 'One Day'), choices=[
('week', 'One Week'), ("day", _("One Day")),
('month', 'One Month'), ("week", _("One Week")),
('forever', 'Does Not Expire')]), ("month", _("One Month")),
'use_limit': widgets.Select( ("forever", _("Does Not Expire")),
choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]] ]
+ [(None, 'Unlimited')]) ),
"use_limit": widgets.Select(
choices=[
(i, _("%(count)d uses" % {"count": i}))
for i in [1, 5, 10, 25, 50, 100]
]
+ [(None, _("Unlimited"))]
),
} }
class ShelfForm(CustomForm): class ShelfForm(CustomForm):
class Meta: class Meta:
model = models.Shelf model = models.Shelf
fields = ['user', 'name', 'privacy'] fields = ["user", "name", "privacy"]
class GoalForm(CustomForm): class GoalForm(CustomForm):
class Meta: class Meta:
model = models.AnnualGoal model = models.AnnualGoal
fields = ['user', 'year', 'goal', 'privacy'] fields = ["user", "year", "goal", "privacy"]
class SiteForm(CustomForm): class SiteForm(CustomForm):
@ -208,7 +276,42 @@ class SiteForm(CustomForm):
exclude = [] exclude = []
class AnnouncementForm(CustomForm):
class Meta:
model = models.Announcement
exclude = ["remote_id"]
class ListForm(CustomForm): class ListForm(CustomForm):
class Meta: class Meta:
model = models.List model = models.List
fields = ['user', 'name', 'description', 'curation', 'privacy'] fields = ["user", "name", "description", "curation", "privacy"]
class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "statuses", "note"]
class ServerForm(CustomForm):
class Meta:
model = models.FederatedServer
exclude = ["remote_id"]
class SortListForm(forms.Form):
sort_by = ChoiceField(
choices=(
("order", _("List Order")),
("title", _("Book Title")),
("rating", _("Rating")),
),
label=_("Sort By"),
)
direction = ChoiceField(
choices=(
("ascending", _("Ascending")),
("descending", _("Descending")),
),
)

View file

@ -1,121 +0,0 @@
''' handle reading a csv from goodreads '''
import csv
import logging
from bookwyrm import models
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
def create_job(user, csv_file, include_reviews, privacy):
''' check over a csv and creates a database entry for the job'''
job = ImportJob.objects.create(
user=user,
include_reviews=include_reviews,
privacy=privacy
)
for index, entry in enumerate(list(csv.DictReader(csv_file))):
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
raise ValueError('Author, title, and isbn must be in data.')
ImportItem(job=job, index=index, data=entry).save()
return job
def create_retry_job(user, original_job, items):
''' retry items that didn't import '''
job = ImportJob.objects.create(
user=user,
include_reviews=original_job.include_reviews,
privacy=original_job.privacy,
retry=True
)
for item in items:
ImportItem(job=job, index=item.index, data=item.data).save()
return job
def start_import(job):
''' initalizes a csv import job '''
result = import_data.delay(job.id)
job.task_id = result.id
job.save()
@app.task
def import_data(job_id):
''' does the actual lookup work in a celery task '''
job = ImportJob.objects.get(id=job_id)
try:
for item in job.items.all():
try:
item.resolve()
except Exception as e:# pylint: disable=broad-except
logger.exception(e)
item.fail_reason = 'Error loading book'
item.save()
continue
if item.book:
item.save()
# shelves book and handles reviews
handle_imported_book(
job.user, item, job.include_reviews, job.privacy)
else:
item.fail_reason = 'Could not find a match for book'
item.save()
finally:
job.complete = True
job.save()
def handle_imported_book(user, item, include_reviews, privacy):
''' process a goodreads csv and then post about it '''
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
return
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, user=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
models.ShelfBook.objects.create(
book=item.book, shelf=desired_shelf, user=user)
for read in item.reads:
# check for an existing readthrough with the same dates
if models.ReadThrough.objects.filter(
user=user, book=item.book,
start_date=read.start_date,
finish_date=read.finish_date
).exists():
continue
read.book = item.book
read.user = user
read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format(
item.book.title,
) if item.review else ''
# 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
models.Review.objects.create(
user=user,
book=item.book,
name=review_title,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
privacy=privacy,
)

View file

@ -0,0 +1,6 @@
""" import classes """
from .importer import Importer
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
from .storygraph_import import StorygraphImporter

View file

@ -0,0 +1,16 @@
""" handle reading a csv from goodreads """
from . import Importer
class GoodreadsImporter(Importer):
"""GoodReads is the default importer, thus Importer follows its structure.
For a more complete example of overriding see librarything_import.py"""
service = "GoodReads"
def parse_fields(self, entry):
"""handle the specific fields in goodreads csvs"""
entry.update({"import_source": self.service})
# add missing 'Date Started' field
entry.update({"Date Started": None})
return entry

View file

@ -0,0 +1,148 @@
""" handle reading a csv from an external service, defaults are from GoodReads """
import csv
import logging
from bookwyrm import models
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
class Importer:
"""Generic class for csv data import from an outside service"""
service = "Unknown"
delimiter = ","
encoding = "UTF-8"
mandatory_fields = ["Title", "Author"]
def create_job(self, user, csv_file, include_reviews, privacy):
"""check over a csv and creates a database entry for the job"""
job = ImportJob.objects.create(
user=user, include_reviews=include_reviews, privacy=privacy
)
for index, entry in enumerate(
list(csv.DictReader(csv_file, delimiter=self.delimiter))
):
if not all(x in entry for x in self.mandatory_fields):
raise ValueError("Author and title must be in data.")
entry = self.parse_fields(entry)
self.save_item(job, index, entry)
return job
def save_item(self, job, index, data): # pylint: disable=no-self-use
"""creates and saves an import item"""
ImportItem(job=job, index=index, data=data).save()
def parse_fields(self, entry):
"""updates csv data with additional info"""
entry.update({"import_source": self.service})
return entry
def create_retry_job(self, user, original_job, items):
"""retry items that didn't import"""
job = ImportJob.objects.create(
user=user,
include_reviews=original_job.include_reviews,
privacy=original_job.privacy,
retry=True,
)
for item in items:
self.save_item(job, item.index, item.data)
return job
def start_import(self, job):
"""initalizes a csv import job"""
result = import_data.delay(self.service, job.id)
job.task_id = result.id
job.save()
@app.task
def import_data(source, job_id):
"""does the actual lookup work in a celery task"""
job = ImportJob.objects.get(id=job_id)
try:
for item in job.items.all():
try:
item.resolve()
except Exception as e: # pylint: disable=broad-except
logger.exception(e)
item.fail_reason = "Error loading book"
item.save()
continue
if item.book:
item.save()
# shelves book and handles reviews
handle_imported_book(
source, job.user, item, job.include_reviews, job.privacy
)
else:
item.fail_reason = "Could not find a match for book"
item.save()
finally:
job.complete = True
job.save()
def handle_imported_book(source, user, item, include_reviews, privacy):
"""process a csv and then post about it"""
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
return
existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
models.ShelfBook.objects.create(book=item.book, shelf=desired_shelf, user=user)
for read in item.reads:
# check for an existing readthrough with the same dates
if models.ReadThrough.objects.filter(
user=user,
book=item.book,
start_date=read.start_date,
finish_date=read.finish_date,
).exists():
continue
read.book = item.book
read.user = user
read.save()
if include_reviews and (item.rating or item.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
if item.review:
review_title = (
"Review of {!r} on {!r}".format(
item.book.title,
source,
)
if item.review
else ""
)
models.Review.objects.create(
user=user,
book=item.book,
name=review_title,
content=item.review,
rating=item.rating,
published_date=published_date_guess,
privacy=privacy,
)
else:
# just a rating
models.ReviewRating.objects.create(
user=user,
book=item.book,
rating=item.rating,
published_date=published_date_guess,
privacy=privacy,
)

View file

@ -0,0 +1,42 @@
""" handle reading a csv from librarything """
import re
import math
from . import Importer
class LibrarythingImporter(Importer):
"""csv downloads from librarything"""
service = "LibraryThing"
delimiter = "\t"
encoding = "ISO-8859-1"
# mandatory_fields : fields matching the book title and author
mandatory_fields = ["Title", "Primary Author"]
def parse_fields(self, entry):
"""custom parsing for librarything"""
data = {}
data["import_source"] = self.service
data["Book Id"] = entry["Book Id"]
data["Title"] = entry["Title"]
data["Author"] = entry["Primary Author"]
data["ISBN13"] = entry["ISBN"]
data["My Review"] = entry["Review"]
if entry["Rating"]:
data["My Rating"] = math.ceil(float(entry["Rating"]))
else:
data["My Rating"] = ""
data["Date Added"] = re.sub(r"\[|\]", "", entry["Entry Date"])
data["Date Started"] = re.sub(r"\[|\]", "", entry["Date Started"])
data["Date Read"] = re.sub(r"\[|\]", "", entry["Date Read"])
data["Exclusive Shelf"] = None
if data["Date Read"]:
data["Exclusive Shelf"] = "read"
elif data["Date Started"]:
data["Exclusive Shelf"] = "reading"
else:
data["Exclusive Shelf"] = "to-read"
return data

View file

@ -0,0 +1,34 @@
""" handle reading a csv from librarything """
import re
import math
from . import Importer
class StorygraphImporter(Importer):
"""csv downloads from librarything"""
service = "Storygraph"
# mandatory_fields : fields matching the book title and author
mandatory_fields = ["Title"]
def parse_fields(self, entry):
"""custom parsing for storygraph"""
data = {}
data["import_source"] = self.service
data["Title"] = entry["Title"]
data["Author"] = entry["Authors"] if "Authors" in entry else entry["Author"]
data["ISBN13"] = entry["ISBN"]
data["My Review"] = entry["Review"]
if entry["Star Rating"]:
data["My Rating"] = math.ceil(float(entry["Star Rating"]))
else:
data["My Rating"] = ""
data["Date Added"] = re.sub(r"[/]", "-", entry["Date Added"])
data["Date Read"] = re.sub(r"[/]", "-", entry["Last Date Read"])
data["Exclusive Shelf"] = (
{"read": "read", "currently-reading": "reading", "to-read": "to-read"}
).get(entry["Read Status"], None)
return data

View file

@ -1,360 +0,0 @@
''' handles all of the activity coming in to the server '''
import json
from urllib.parse import urldefrag
import django.db.utils
from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
import requests
from bookwyrm import activitypub, models
from bookwyrm import status as status_builder
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@csrf_exempt
@require_POST
def inbox(request, username):
''' incoming activitypub events '''
try:
models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
return shared_inbox(request)
@csrf_exempt
@require_POST
def shared_inbox(request):
''' incoming activitypub events '''
try:
resp = request.body
activity = json.loads(resp)
if isinstance(activity, str):
activity = json.loads(activity)
activity_object = activity['object']
except (json.decoder.JSONDecodeError, KeyError):
return HttpResponseBadRequest()
if not has_valid_signature(request, activity):
if activity['type'] == 'Delete':
# Pretend that unauth'd deletes succeed. Auth may be failing because
# the resource or owner of the resource might have been deleted.
return HttpResponse()
return HttpResponse(status=401)
# if this isn't a file ripe for refactor, I don't know what is.
handlers = {
'Follow': handle_follow,
'Accept': handle_follow_accept,
'Reject': handle_follow_reject,
'Block': handle_block,
'Create': {
'BookList': handle_create_list,
'Note': handle_create_status,
'Article': handle_create_status,
'Review': handle_create_status,
'Comment': handle_create_status,
'Quotation': handle_create_status,
},
'Delete': handle_delete_status,
'Like': handle_favorite,
'Announce': handle_boost,
'Add': {
'Edition': handle_add,
},
'Undo': {
'Follow': handle_unfollow,
'Like': handle_unfavorite,
'Announce': handle_unboost,
'Block': handle_unblock,
},
'Update': {
'Person': handle_update_user,
'Edition': handle_update_edition,
'Work': handle_update_work,
'BookList': handle_update_list,
},
}
activity_type = activity['type']
handler = handlers.get(activity_type, None)
if isinstance(handler, dict):
handler = handler.get(activity_object['type'], None)
if not handler:
return HttpResponseNotFound()
handler.delay(activity)
return HttpResponse()
def has_valid_signature(request, activity):
''' verify incoming signature '''
try:
signature = Signature.parse(request)
key_actor = urldefrag(signature.key_id).url
if key_actor != activity.get('actor'):
raise ValueError("Wrong actor created signature.")
remote_user = activitypub.resolve_remote_id(models.User, key_actor)
if not remote_user:
return False
try:
signature.verify(remote_user.key_pair.public_key, request)
except ValueError:
old_key = remote_user.key_pair.public_key
remote_user = activitypub.resolve_remote_id(
models.User, remote_user.remote_id, refresh=True
)
if remote_user.key_pair.public_key == old_key:
raise # Key unchanged.
signature.verify(remote_user.key_pair.public_key, request)
except (ValueError, requests.exceptions.HTTPError):
return False
return True
@app.task
def handle_follow(activity):
''' someone wants to follow a local user '''
try:
relationship = activitypub.Follow(
**activity
).to_model(models.UserFollowRequest)
except django.db.utils.IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
relationship = models.UserFollowRequest.objects.get(
remote_id=activity['id']
)
# send the accept normally for a duplicate request
if not relationship.user_object.manually_approves_followers:
relationship.accept()
@app.task
def handle_unfollow(activity):
''' unfollow a local user '''
obj = activity['object']
requester = activitypub.resolve_remote_id(models.User, obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object'])
# raises models.User.DoesNotExist
to_unfollow.followers.remove(requester)
@app.task
def handle_follow_accept(activity):
''' hurray, someone remote accepted a follow request '''
# figure out who they want to follow
requester = models.User.objects.get(remote_id=activity['object']['actor'])
# figure out who they are
accepter = activitypub.resolve_remote_id(models.User, activity['actor'])
try:
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=accepter
)
request.delete()
except models.UserFollowRequest.DoesNotExist:
pass
accepter.followers.add(requester)
@app.task
def handle_follow_reject(activity):
''' someone is rejecting a follow request '''
requester = models.User.objects.get(remote_id=activity['object']['actor'])
rejecter = activitypub.resolve_remote_id(models.User, activity['actor'])
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=rejecter
)
request.delete()
#raises models.UserFollowRequest.DoesNotExist
@app.task
def handle_block(activity):
''' blocking a user '''
# create "block" databse entry
activitypub.Block(**activity).to_model(models.UserBlocks)
# the removing relationships is handled in post-save hook in model
@app.task
def handle_unblock(activity):
''' undoing a block '''
try:
block_id = activity['object']['id']
except KeyError:
return
try:
block = models.UserBlocks.objects.get(remote_id=block_id)
except models.UserBlocks.DoesNotExist:
return
block.delete()
@app.task
def handle_create_list(activity):
''' a new list '''
activity = activity['object']
activitypub.BookList(**activity).to_model(models.List)
@app.task
def handle_update_list(activity):
''' update a list '''
try:
book_list = models.List.objects.get(remote_id=activity['object']['id'])
except models.List.DoesNotExist:
book_list = None
activitypub.BookList(
**activity['object']).to_model(models.List, instance=book_list)
@app.task
def handle_create_status(activity):
''' someone did something, good on them '''
# deduplicate incoming activities
activity = activity['object']
status_id = activity.get('id')
if models.Status.objects.filter(remote_id=status_id).count():
return
try:
serializer = activitypub.activity_objects[activity['type']]
except KeyError:
return
activity = serializer(**activity)
try:
model = models.activity_models[activity.type]
except KeyError:
# not a type of status we are prepared to deserialize
return
status = activity.to_model(model)
if not status:
# it was discarded because it's not a bookwyrm type
return
@app.task
def handle_delete_status(activity):
''' remove a status '''
try:
status_id = activity['object']['id']
except TypeError:
# this isn't a great fix, because you hit this when mastadon
# is trying to delete a user.
return
try:
status = models.Status.objects.get(
remote_id=status_id
)
except models.Status.DoesNotExist:
return
models.Notification.objects.filter(related_status=status).all().delete()
status_builder.delete_status(status)
@app.task
def handle_favorite(activity):
''' approval of your good good post '''
fav = activitypub.Like(**activity)
# we dont know this status, we don't care about this status
if not models.Status.objects.filter(remote_id=fav.object).exists():
return
fav = fav.to_model(models.Favorite)
if fav.user.local:
return
@app.task
def handle_unfavorite(activity):
''' approval of your good good post '''
like = models.Favorite.objects.filter(
remote_id=activity['object']['id']
).first()
if not like:
return
like.delete()
@app.task
def handle_boost(activity):
''' someone gave us a boost! '''
try:
activitypub.Boost(**activity).to_model(models.Boost)
except activitypub.ActivitySerializerError:
# this probably just means we tried to boost an unknown status
return
@app.task
def handle_unboost(activity):
''' someone gave us a boost! '''
boost = models.Boost.objects.filter(
remote_id=activity['object']['id']
).first()
if boost:
boost.delete()
@app.task
def handle_add(activity):
''' putting a book on a shelf '''
#this is janky as heck but I haven't thought of a better solution
try:
activitypub.AddBook(**activity).to_model(models.ShelfBook)
return
except activitypub.ActivitySerializerError:
pass
try:
activitypub.AddListItem(**activity).to_model(models.ListItem)
return
except activitypub.ActivitySerializerError:
pass
try:
activitypub.AddBook(**activity).to_model(models.UserTag)
return
except activitypub.ActivitySerializerError:
pass
@app.task
def handle_update_user(activity):
''' receive an updated user Person activity object '''
try:
user = models.User.objects.get(remote_id=activity['object']['id'])
except models.User.DoesNotExist:
# who is this person? who cares
return
activitypub.Person(
**activity['object']
).to_model(models.User, instance=user)
# model save() happens in the to_model function
@app.task
def handle_update_edition(activity):
''' a remote instance changed a book (Document) '''
activitypub.Edition(**activity['object']).to_model(models.Edition)
@app.task
def handle_update_work(activity):
''' a remote instance changed a book (Document) '''
activitypub.Work(**activity['object']).to_model(models.Work)

View file

@ -1,26 +1,20 @@
''' PROCEED WITH CAUTION: uses deduplication fields to permanently """ PROCEED WITH CAUTION: uses deduplication fields to permanently
merge book data objects ''' merge book data objects """
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import Count from django.db.models import Count
from bookwyrm import models from bookwyrm import models
def update_related(canonical, obj): def update_related(canonical, obj):
''' update all the models with fk to the object being removed ''' """update all the models with fk to the object being removed"""
# move related models to canonical # move related models to canonical
related_models = [ related_models = [
(r.remote_field.name, r.related_model) for r in \ (r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
canonical._meta.related_objects] ]
for (related_field, related_model) in related_models: for (related_field, related_model) in related_models:
related_objs = related_model.objects.filter( related_objs = related_model.objects.filter(**{related_field: obj})
**{related_field: obj})
for related_obj in related_objs: for related_obj in related_objs:
print( print("replacing in", related_model.__name__, related_field, related_obj.id)
'replacing in',
related_model.__name__,
related_field,
related_obj.id
)
try: try:
setattr(related_obj, related_field, canonical) setattr(related_obj, related_field, canonical)
related_obj.save() related_obj.save()
@ -30,40 +24,41 @@ def update_related(canonical, obj):
def copy_data(canonical, obj): def copy_data(canonical, obj):
''' try to get the most data possible ''' """try to get the most data possible"""
for data_field in obj._meta.get_fields(): for data_field in obj._meta.get_fields():
if not hasattr(data_field, 'activitypub_field'): if not hasattr(data_field, "activitypub_field"):
continue continue
data_value = getattr(obj, data_field.name) data_value = getattr(obj, data_field.name)
if not data_value: if not data_value:
continue continue
if not getattr(canonical, data_field.name): if not getattr(canonical, data_field.name):
print('setting data field', data_field.name, data_value) print("setting data field", data_field.name, data_value)
setattr(canonical, data_field.name, data_value) setattr(canonical, data_field.name, data_value)
canonical.save() canonical.save()
def dedupe_model(model): def dedupe_model(model):
''' combine duplicate editions and update related models ''' """combine duplicate editions and update related models"""
fields = model._meta.get_fields() fields = model._meta.get_fields()
dedupe_fields = [f for f in fields if \ dedupe_fields = [
hasattr(f, 'deduplication_field') and f.deduplication_field] f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
]
for field in dedupe_fields: for field in dedupe_fields:
dupes = model.objects.values(field.name).annotate( dupes = (
Count(field.name) model.objects.values(field.name)
).filter(**{'%s__count__gt' % field.name: 1}) .annotate(Count(field.name))
.filter(**{"%s__count__gt" % field.name: 1})
)
for dupe in dupes: for dupe in dupes:
value = dupe[field.name] value = dupe[field.name]
if not value or value == '': if not value or value == "":
continue continue
print('----------') print("----------")
print(dupe) print(dupe)
objs = model.objects.filter( objs = model.objects.filter(**{field.name: value}).order_by("id")
**{field.name: value}
).order_by('id')
canonical = objs.first() canonical = objs.first()
print('keeping', canonical.remote_id) print("keeping", canonical.remote_id)
for obj in objs[1:]: for obj in objs[1:]:
print(obj.remote_id) print(obj.remote_id)
copy_data(canonical, obj) copy_data(canonical, obj)
@ -73,11 +68,12 @@ def dedupe_model(model):
class Command(BaseCommand): class Command(BaseCommand):
''' dedplucate allllll the book data models ''' """dedplucate allllll the book data models"""
help = 'merges duplicate book data'
help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument # pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options): def handle(self, *args, **options):
''' run deudplications ''' """run deudplications"""
dedupe_model(models.Edition) dedupe_model(models.Edition)
dedupe_model(models.Work) dedupe_model(models.Work)
dedupe_model(models.Author) dedupe_model(models.Author)

View file

@ -0,0 +1,24 @@
""" Delete user streams """
from django.core.management.base import BaseCommand
import redis
from bookwyrm import settings
r = redis.Redis(
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
)
def erase_streams():
"""throw the whole redis away"""
r.flushall()
class Command(BaseCommand):
"""delete activity streams for all users"""
help = "Delete all the user streams"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""flush all, baby"""
erase_streams()

View file

@ -1,55 +1,70 @@
from django.core.management.base import BaseCommand, CommandError """ What you need in the database to make it work """
from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from bookwyrm.models import Connector, SiteSettings, User from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
def init_groups(): def init_groups():
groups = ['admin', 'moderator', 'editor'] """permission levels"""
groups = ["admin", "moderator", "editor"]
for group in groups: for group in groups:
Group.objects.create(name=group) Group.objects.create(name=group)
def init_permissions(): def init_permissions():
permissions = [{ """permission types"""
'codename': 'edit_instance_settings', permissions = [
'name': 'change the instance info', {
'groups': ['admin',] "codename": "edit_instance_settings",
}, { "name": "change the instance info",
'codename': 'set_user_group', "groups": [
'name': 'change what group a user is in', "admin",
'groups': ['admin', 'moderator'] ],
}, { },
'codename': 'control_federation', {
'name': 'control who to federate with', "codename": "set_user_group",
'groups': ['admin', 'moderator'] "name": "change what group a user is in",
}, { "groups": ["admin", "moderator"],
'codename': 'create_invites', },
'name': 'issue invitations to join', {
'groups': ['admin', 'moderator'] "codename": "control_federation",
}, { "name": "control who to federate with",
'codename': 'moderate_user', "groups": ["admin", "moderator"],
'name': 'deactivate or silence a user', },
'groups': ['admin', 'moderator'] {
}, { "codename": "create_invites",
'codename': 'moderate_post', "name": "issue invitations to join",
'name': 'delete other users\' posts', "groups": ["admin", "moderator"],
'groups': ['admin', 'moderator'] },
}, { {
'codename': 'edit_book', "codename": "moderate_user",
'name': 'edit book info', "name": "deactivate or silence a user",
'groups': ['admin', 'moderator', 'editor'] "groups": ["admin", "moderator"],
}] },
{
"codename": "moderate_post",
"name": "delete other users' posts",
"groups": ["admin", "moderator"],
},
{
"codename": "edit_book",
"name": "edit book info",
"groups": ["admin", "moderator", "editor"],
},
]
content_type = ContentType.objects.get_for_model(User) content_type = ContentType.objects.get_for_model(User)
for permission in permissions: for permission in permissions:
permission_obj = Permission.objects.create( permission_obj = Permission.objects.create(
codename=permission['codename'], codename=permission["codename"],
name=permission['name'], name=permission["name"],
content_type=content_type, content_type=content_type,
) )
# add the permission to the appropriate groups # add the permission to the appropriate groups
for group_name in permission['groups']: for group_name in permission["groups"]:
Group.objects.get(name=group_name).permissions.add(permission_obj) Group.objects.get(name=group_name).permissions.add(permission_obj)
# while the groups and permissions shouldn't be changed because the code # while the groups and permissions shouldn't be changed because the code
@ -57,48 +72,81 @@ def init_permissions():
def init_connectors(): def init_connectors():
"""access book data sources"""
Connector.objects.create( Connector.objects.create(
identifier=DOMAIN, identifier=DOMAIN,
name='Local', name="Local",
local=True, local=True,
connector_file='self_connector', connector_file="self_connector",
base_url='https://%s' % DOMAIN, base_url="https://%s" % DOMAIN,
books_url='https://%s/book' % DOMAIN, books_url="https://%s/book" % DOMAIN,
covers_url='https://%s/images/covers' % DOMAIN, covers_url="https://%s/images/" % DOMAIN,
search_url='https://%s/search?q=' % DOMAIN, search_url="https://%s/search?q=" % DOMAIN,
isbn_search_url="https://%s/isbn/" % DOMAIN,
priority=1, priority=1,
) )
Connector.objects.create( Connector.objects.create(
identifier='bookwyrm.social', identifier="bookwyrm.social",
name='BookWyrm dot Social', name="BookWyrm dot Social",
connector_file='bookwyrm_connector', connector_file="bookwyrm_connector",
base_url='https://bookwyrm.social', base_url="https://bookwyrm.social",
books_url='https://bookwyrm.social/book', books_url="https://bookwyrm.social/book",
covers_url='https://bookwyrm.social/images/covers', covers_url="https://bookwyrm.social/images/",
search_url='https://bookwyrm.social/search?q=', search_url="https://bookwyrm.social/search?q=",
isbn_search_url="https://bookwyrm.social/isbn/",
priority=2, priority=2,
) )
Connector.objects.create( Connector.objects.create(
identifier='openlibrary.org', identifier="inventaire.io",
name='OpenLibrary', name="Inventaire",
connector_file='openlibrary', connector_file="inventaire",
base_url='https://openlibrary.org', base_url="https://inventaire.io",
books_url='https://openlibrary.org', books_url="https://inventaire.io/api/entities",
covers_url='https://covers.openlibrary.org', covers_url="https://inventaire.io",
search_url='https://openlibrary.org/search?q=', search_url="https://inventaire.io/api/search?types=works&types=works&search=",
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
priority=3, priority=3,
) )
Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
base_url="https://openlibrary.org",
books_url="https://openlibrary.org",
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
priority=3,
)
def init_federated_servers():
"""big no to nazis"""
built_in_blocks = ["gab.ai", "gab.com"]
for server in built_in_blocks:
FederatedServer.objects.create(
server_name=server,
status="blocked",
)
def init_settings(): def init_settings():
SiteSettings.objects.create() """info about the instance"""
SiteSettings.objects.create(
support_link="https://www.patreon.com/bookwyrm",
support_title="Patreon",
)
class Command(BaseCommand): class Command(BaseCommand):
help = 'Initializes the database with starter data' help = "Initializes the database with starter data"
def handle(self, *args, **options): def handle(self, *args, **options):
init_groups() init_groups()
init_permissions() init_permissions()
init_connectors() init_connectors()
init_federated_servers()
init_settings() init_settings()

View file

@ -0,0 +1,30 @@
""" Re-create user streams """
from django.core.management.base import BaseCommand
import redis
from bookwyrm import activitystreams, models, settings
r = redis.Redis(
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
)
def populate_streams():
"""build all the streams for all the users"""
users = models.User.objects.filter(
local=True,
is_active=True,
)
for user in users:
for stream in activitystreams.streams.values():
stream.populate_streams(user)
class Command(BaseCommand):
"""start all over with user streams"""
help = "Populate streams for all users"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
"""run feed builder"""
populate_streams()

View file

@ -1,34 +1,42 @@
''' PROCEED WITH CAUTION: this permanently deletes book data ''' """ PROCEED WITH CAUTION: this permanently deletes book data """
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db.models import Count, Q from django.db.models import Count, Q
from bookwyrm import models from bookwyrm import models
def remove_editions(): def remove_editions():
''' combine duplicate editions and update related models ''' """combine duplicate editions and update related models"""
# not in use # not in use
filters = {'%s__isnull' % r.name: True \ filters = {
for r in models.Edition._meta.related_objects} "%s__isnull" % r.name: True for r in models.Edition._meta.related_objects
}
# no cover, no identifying fields # no cover, no identifying fields
filters['cover'] = '' filters["cover"] = ""
null_fields = {'%s__isnull' % f: True for f in \ null_fields = {
['isbn_10', 'isbn_13', 'oclc_number']} "%s__isnull" % f: True for f in ["isbn_10", "isbn_13", "oclc_number"]
}
editions = models.Edition.objects.filter( editions = (
Q(languages=[]) | Q(languages__contains=['English']), models.Edition.objects.filter(
**filters, **null_fields Q(languages=[]) | Q(languages__contains=["English"]),
).annotate(Count('parent_work__editions')).filter( **filters,
# mustn't be the only edition for the work **null_fields
parent_work__editions__count__gt=1 )
.annotate(Count("parent_work__editions"))
.filter(
# mustn't be the only edition for the work
parent_work__editions__count__gt=1
)
) )
print(editions.count()) print(editions.count())
editions.delete() editions.delete()
class Command(BaseCommand): class Command(BaseCommand):
''' dedplucate allllll the book data models ''' """dedplucate allllll the book data models"""
help = 'merges duplicate book data'
help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument # pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options): def handle(self, *args, **options):
''' run deudplications ''' """run deudplications"""
remove_editions() remove_editions()

View file

@ -15,199 +15,448 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0011_update_proxy_permissions'), ("auth", "0011_update_proxy_permissions"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='User', name="User",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('password', models.CharField(max_length=128, verbose_name='password')), "id",
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), models.AutoField(
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), auto_created=True,
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), primary_key=True,
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')), serialize=False,
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), verbose_name="ID",
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), ),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), ),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), ("password", models.CharField(max_length=128, verbose_name="password")),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), (
('private_key', models.TextField(blank=True, null=True)), "last_login",
('public_key', models.TextField(blank=True, null=True)), models.DateTimeField(
('actor', models.CharField(max_length=255, unique=True)), blank=True, null=True, verbose_name="last login"
('inbox', models.CharField(max_length=255, unique=True)), ),
('shared_inbox', models.CharField(blank=True, max_length=255, null=True)), ),
('outbox', models.CharField(max_length=255, unique=True)), (
('summary', models.TextField(blank=True, null=True)), "is_superuser",
('local', models.BooleanField(default=True)), models.BooleanField(
('fedireads_user', models.BooleanField(default=True)), default=False,
('localname', models.CharField(max_length=255, null=True, unique=True)), help_text="Designates that this user has all permissions without explicitly assigning them.",
('name', models.CharField(blank=True, max_length=100, null=True)), verbose_name="superuser status",
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')), ),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=30, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
("private_key", models.TextField(blank=True, null=True)),
("public_key", models.TextField(blank=True, null=True)),
("actor", models.CharField(max_length=255, unique=True)),
("inbox", models.CharField(max_length=255, unique=True)),
(
"shared_inbox",
models.CharField(blank=True, max_length=255, null=True),
),
("outbox", models.CharField(max_length=255, unique=True)),
("summary", models.TextField(blank=True, null=True)),
("local", models.BooleanField(default=True)),
("fedireads_user", models.BooleanField(default=True)),
("localname", models.CharField(max_length=255, null=True, unique=True)),
("name", models.CharField(blank=True, max_length=100, null=True)),
(
"avatar",
models.ImageField(blank=True, null=True, upload_to="avatars/"),
),
], ],
options={ options={
'verbose_name': 'user', "verbose_name": "user",
'verbose_name_plural': 'users', "verbose_name_plural": "users",
'abstract': False, "abstract": False,
}, },
managers=[ managers=[
('objects', django.contrib.auth.models.UserManager()), ("objects", django.contrib.auth.models.UserManager()),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Author', name="Author",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('openlibrary_key', models.CharField(max_length=255)), auto_created=True,
('data', JSONField()), primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("content", models.TextField(blank=True, null=True)),
("created_date", models.DateTimeField(auto_now_add=True)),
("openlibrary_key", models.CharField(max_length=255)),
("data", JSONField()),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Book', name="Book",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('openlibrary_key', models.CharField(max_length=255, unique=True)), auto_created=True,
('data', JSONField()), primary_key=True,
('cover', models.ImageField(blank=True, null=True, upload_to='covers/')), serialize=False,
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), verbose_name="ID",
('authors', models.ManyToManyField(to='bookwyrm.Author')), ),
),
("content", models.TextField(blank=True, null=True)),
("created_date", models.DateTimeField(auto_now_add=True)),
("openlibrary_key", models.CharField(max_length=255, unique=True)),
("data", JSONField()),
(
"cover",
models.ImageField(blank=True, null=True, upload_to="covers/"),
),
(
"added_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
("authors", models.ManyToManyField(to="bookwyrm.Author")),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='FederatedServer', name="FederatedServer",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('server_name', models.CharField(max_length=255, unique=True)), auto_created=True,
('status', models.CharField(default='federated', max_length=255)), primary_key=True,
('application_type', models.CharField(max_length=255, null=True)), serialize=False,
verbose_name="ID",
),
),
("content", models.TextField(blank=True, null=True)),
("created_date", models.DateTimeField(auto_now_add=True)),
("server_name", models.CharField(max_length=255, unique=True)),
("status", models.CharField(default="federated", max_length=255)),
("application_type", models.CharField(max_length=255, null=True)),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='Shelf', name="Shelf",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('name', models.CharField(max_length=100)), auto_created=True,
('identifier', models.CharField(max_length=100)), primary_key=True,
('editable', models.BooleanField(default=True)), serialize=False,
verbose_name="ID",
),
),
("content", models.TextField(blank=True, null=True)),
("created_date", models.DateTimeField(auto_now_add=True)),
("name", models.CharField(max_length=100)),
("identifier", models.CharField(max_length=100)),
("editable", models.BooleanField(default=True)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Status', name="Status",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('status_type', models.CharField(default='Note', max_length=255)), auto_created=True,
('activity_type', models.CharField(default='Note', max_length=255)), primary_key=True,
('local', models.BooleanField(default=True)), serialize=False,
('privacy', models.CharField(default='public', max_length=255)), verbose_name="ID",
('sensitive', models.BooleanField(default=False)), ),
('mention_books', models.ManyToManyField(related_name='mention_book', to='bookwyrm.Book')), ),
('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)), ("content", models.TextField(blank=True, null=True)),
('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')), ("created_date", models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ("status_type", models.CharField(default="Note", max_length=255)),
("activity_type", models.CharField(default="Note", max_length=255)),
("local", models.BooleanField(default=True)),
("privacy", models.CharField(default="public", max_length=255)),
("sensitive", models.BooleanField(default=False)),
(
"mention_books",
models.ManyToManyField(
related_name="mention_book", to="bookwyrm.Book"
),
),
(
"mention_users",
models.ManyToManyField(
related_name="mention_user", to=settings.AUTH_USER_MODEL
),
),
(
"reply_parent",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Status",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='UserRelationship', name="UserRelationship",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('status', models.CharField(default='follows', max_length=100, null=True)), auto_created=True,
('relationship_id', models.CharField(max_length=100)), primary_key=True,
('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_object', to=settings.AUTH_USER_MODEL)), serialize=False,
('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_subject', to=settings.AUTH_USER_MODEL)), verbose_name="ID",
),
),
("content", models.TextField(blank=True, null=True)),
("created_date", models.DateTimeField(auto_now_add=True)),
(
"status",
models.CharField(default="follows", max_length=100, null=True),
),
("relationship_id", models.CharField(max_length=100)),
(
"user_object",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="user_object",
to=settings.AUTH_USER_MODEL,
),
),
(
"user_subject",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="user_subject",
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
migrations.CreateModel( migrations.CreateModel(
name='ShelfBook', name="ShelfBook",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), auto_created=True,
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), primary_key=True,
('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Shelf')), serialize=False,
verbose_name="ID",
),
),
("content", models.TextField(blank=True, null=True)),
("created_date", models.DateTimeField(auto_now_add=True)),
(
"added_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
),
),
(
"shelf",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Shelf"
),
),
], ],
options={ options={
'unique_together': {('book', 'shelf')}, "unique_together": {("book", "shelf")},
}, },
), ),
migrations.AddField( migrations.AddField(
model_name='shelf', model_name="shelf",
name='books', name="books",
field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Book'), field=models.ManyToManyField(
through="bookwyrm.ShelfBook", to="bookwyrm.Book"
),
), ),
migrations.AddField( migrations.AddField(
model_name='shelf', model_name="shelf",
name='user', name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
), ),
migrations.AddField( migrations.AddField(
model_name='book', model_name="book",
name='shelves', name="shelves",
field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Shelf'), field=models.ManyToManyField(
through="bookwyrm.ShelfBook", to="bookwyrm.Shelf"
),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='federated_server', name="federated_server",
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.FederatedServer'), field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.FederatedServer",
),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='followers', name="followers",
field=models.ManyToManyField(through='bookwyrm.UserRelationship', to=settings.AUTH_USER_MODEL), field=models.ManyToManyField(
through="bookwyrm.UserRelationship", to=settings.AUTH_USER_MODEL
),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='groups', name="groups",
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'), field=models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.Group",
verbose_name="groups",
),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='user_permissions', name="user_permissions",
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), field=models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.Permission",
verbose_name="user permissions",
),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='shelf', name="shelf",
unique_together={('user', 'identifier')}, unique_together={("user", "identifier")},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Review', name="Review",
fields=[ fields=[
('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')), (
('name', models.CharField(max_length=255)), "status_ptr",
('rating', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])), models.OneToOneField(
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookwyrm.Status",
),
),
("name", models.CharField(max_length=255)),
(
"rating",
models.IntegerField(
default=0,
validators=[
django.core.validators.MinValueValidator(0),
django.core.validators.MaxValueValidator(5),
],
),
),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
bases=('bookwyrm.status',), bases=("bookwyrm.status",),
), ),
] ]

View file

@ -8,31 +8,59 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0001_initial'), ("bookwyrm", "0001_initial"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Favorite', name="Favorite",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('content', models.TextField(blank=True, null=True)), "id",
('created_date', models.DateTimeField(auto_now_add=True)), models.AutoField(
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')), auto_created=True,
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("content", models.TextField(blank=True, null=True)),
("created_date", models.DateTimeField(auto_now_add=True)),
(
"status",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Status",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'unique_together': {('user', 'status')}, "unique_together": {("user", "status")},
}, },
), ),
migrations.AddField( migrations.AddField(
model_name='status', model_name="status",
name='favorites', name="favorites",
field=models.ManyToManyField(related_name='user_favorites', through='bookwyrm.Favorite', to=settings.AUTH_USER_MODEL), field=models.ManyToManyField(
related_name="user_favorites",
through="bookwyrm.Favorite",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='favorites', name="favorites",
field=models.ManyToManyField(related_name='favorite_statuses', through='bookwyrm.Favorite', to='bookwyrm.Status'), field=models.ManyToManyField(
related_name="favorite_statuses",
through="bookwyrm.Favorite",
to="bookwyrm.Status",
),
), ),
] ]

View file

@ -7,87 +7,89 @@ import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0002_auto_20200219_0816'), ("bookwyrm", "0002_auto_20200219_0816"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='author', model_name="author",
name='content', name="content",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='book', model_name="book",
name='content', name="content",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='favorite', model_name="favorite",
name='content', name="content",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='federatedserver', model_name="federatedserver",
name='content', name="content",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='shelf', model_name="shelf",
name='content', name="content",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='shelfbook', model_name="shelfbook",
name='content', name="content",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='userrelationship', model_name="userrelationship",
name='content', name="content",
), ),
migrations.AddField( migrations.AddField(
model_name='author', model_name="author",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='book', model_name="book",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='favorite', model_name="favorite",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='federatedserver', model_name="federatedserver",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='shelf', model_name="shelf",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='shelfbook', model_name="shelfbook",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='status', model_name="status",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='created_date', name="created_date",
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False, preserve_default=False,
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
migrations.AddField( migrations.AddField(
model_name='userrelationship', model_name="userrelationship",
name='updated_date', name="updated_date",
field=models.DateTimeField(auto_now=True), field=models.DateTimeField(auto_now=True),
), ),
] ]

View file

@ -8,22 +8,41 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0003_auto_20200221_0131'), ("bookwyrm", "0003_auto_20200221_0131"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Tag', name="Tag",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('created_date', models.DateTimeField(auto_now_add=True)), "id",
('updated_date', models.DateTimeField(auto_now=True)), models.AutoField(
('name', models.CharField(max_length=140)), auto_created=True,
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')), primary_key=True,
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=140)),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'unique_together': {('user', 'book', 'name')}, "unique_together": {("user", "book", "name")},
}, },
), ),
] ]

View file

@ -5,27 +5,27 @@ from django.db import migrations, models
def populate_identifiers(app_registry, schema_editor): def populate_identifiers(app_registry, schema_editor):
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
tags = app_registry.get_model('bookwyrm', 'Tag') tags = app_registry.get_model("bookwyrm", "Tag")
for tag in tags.objects.using(db_alias): for tag in tags.objects.using(db_alias):
tag.identifier = re.sub(r'\W+', '-', tag.name).lower() tag.identifier = re.sub(r"\W+", "-", tag.name).lower()
tag.save() tag.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0004_tag'), ("bookwyrm", "0004_tag"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='tag', model_name="tag",
name='identifier', name="identifier",
field=models.CharField(max_length=100, null=True), field=models.CharField(max_length=100, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='tag', model_name="tag",
name='name', name="name",
field=models.CharField(max_length=100), field=models.CharField(max_length=100),
), ),
migrations.RunPython(populate_identifiers), migrations.RunPython(populate_identifiers),

View file

@ -8,13 +8,15 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0006_auto_20200221_1702_squashed_0064_merge_20201101_1913'), ("bookwyrm", "0006_auto_20200221_1702_squashed_0064_merge_20201101_1913"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='siteinvite', model_name="siteinvite",
name='user', name="user",
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
), ),
] ]

View file

@ -6,8 +6,8 @@ import django.db.models.deletion
def set_default_edition(app_registry, schema_editor): def set_default_edition(app_registry, schema_editor):
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
works = app_registry.get_model('bookwyrm', 'Work').objects.using(db_alias) works = app_registry.get_model("bookwyrm", "Work").objects.using(db_alias)
editions = app_registry.get_model('bookwyrm', 'Edition').objects.using(db_alias) editions = app_registry.get_model("bookwyrm", "Edition").objects.using(db_alias)
for work in works: for work in works:
ed = editions.filter(parent_work=work, default=True).first() ed = editions.filter(parent_work=work, default=True).first()
if not ed: if not ed:
@ -15,21 +15,26 @@ def set_default_edition(app_registry, schema_editor):
work.default_edition = ed work.default_edition = ed
work.save() work.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0007_auto_20201103_0014'), ("bookwyrm", "0007_auto_20201103_0014"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='work', model_name="work",
name='default_edition', name="default_edition",
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Edition",
),
), ),
migrations.RunPython(set_default_edition), migrations.RunPython(set_default_edition),
migrations.RemoveField( migrations.RemoveField(
model_name='edition', model_name="edition",
name='default', name="default",
), ),
] ]

View file

@ -6,13 +6,22 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0008_work_default_edition'), ("bookwyrm", "0008_work_default_edition"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='shelf', model_name="shelf",
name='privacy', name="privacy",
field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), field=models.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
), ),
] ]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0009_shelf_privacy'), ("bookwyrm", "0009_shelf_privacy"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='importjob', model_name="importjob",
name='retry', name="retry",
field=models.BooleanField(default=False), field=models.BooleanField(default=False),
), ),
] ]

View file

@ -2,9 +2,10 @@
from django.db import migrations, models from django.db import migrations, models
def set_origin_id(app_registry, schema_editor): def set_origin_id(app_registry, schema_editor):
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
books = app_registry.get_model('bookwyrm', 'Book').objects.using(db_alias) books = app_registry.get_model("bookwyrm", "Book").objects.using(db_alias)
for book in books: for book in books:
book.origin_id = book.remote_id book.origin_id = book.remote_id
# the remote_id will be set automatically # the remote_id will be set automatically
@ -15,18 +16,18 @@ def set_origin_id(app_registry, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0010_importjob_retry'), ("bookwyrm", "0010_importjob_retry"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='author', model_name="author",
name='origin_id', name="origin_id",
field=models.CharField(max_length=255, null=True), field=models.CharField(max_length=255, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name='book', model_name="book",
name='origin_id', name="origin_id",
field=models.CharField(max_length=255, null=True), field=models.CharField(max_length=255, null=True),
), ),
migrations.RunPython(set_origin_id), migrations.RunPython(set_origin_id),

View file

@ -7,23 +7,41 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0011_auto_20201113_1727'), ("bookwyrm", "0011_auto_20201113_1727"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Attachment', name="Attachment",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('created_date', models.DateTimeField(auto_now_add=True)), "id",
('updated_date', models.DateTimeField(auto_now=True)), models.AutoField(
('remote_id', models.CharField(max_length=255, null=True)), auto_created=True,
('image', models.ImageField(blank=True, null=True, upload_to='status/')), primary_key=True,
('caption', models.TextField(blank=True, null=True)), serialize=False,
('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status')), verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
("remote_id", models.CharField(max_length=255, null=True)),
(
"image",
models.ImageField(blank=True, null=True, upload_to="status/"),
),
("caption", models.TextField(blank=True, null=True)),
(
"status",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="bookwyrm.Status",
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
] ]

View file

@ -8,24 +8,51 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0011_auto_20201113_1727'), ("bookwyrm", "0011_auto_20201113_1727"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='ProgressUpdate', name="ProgressUpdate",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('created_date', models.DateTimeField(auto_now_add=True)), "id",
('updated_date', models.DateTimeField(auto_now=True)), models.AutoField(
('remote_id', models.CharField(max_length=255, null=True)), auto_created=True,
('progress', models.IntegerField()), primary_key=True,
('mode', models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3)), serialize=False,
('readthrough', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ReadThrough')), verbose_name="ID",
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
("remote_id", models.CharField(max_length=255, null=True)),
("progress", models.IntegerField()),
(
"mode",
models.CharField(
choices=[("PG", "page"), ("PCT", "percent")],
default="PG",
max_length=3,
),
),
(
"readthrough",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.ReadThrough",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
), ),
] ]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0012_attachment'), ("bookwyrm", "0012_attachment"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='origin_id', name="origin_id",
field=models.CharField(blank=True, max_length=255, null=True), field=models.CharField(blank=True, max_length=255, null=True),
), ),
] ]

View file

@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0013_book_origin_id'), ("bookwyrm", "0013_book_origin_id"),
] ]
operations = [ operations = [
migrations.RenameModel( migrations.RenameModel(
old_name='Attachment', old_name="Attachment",
new_name='Image', new_name="Image",
), ),
] ]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0013_book_origin_id'), ("bookwyrm", "0013_book_origin_id"),
('bookwyrm', '0012_progressupdate'), ("bookwyrm", "0012_progressupdate"),
] ]
operations = [ operations = []
]

View file

@ -7,13 +7,18 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0014_auto_20201128_0118'), ("bookwyrm", "0014_auto_20201128_0118"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='image', model_name="image",
name='status', name="status",
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status'), field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="bookwyrm.Status",
),
), ),
] ]

View file

@ -6,18 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0014_merge_20201128_0007'), ("bookwyrm", "0014_merge_20201128_0007"),
] ]
operations = [ operations = [
migrations.RenameField( migrations.RenameField(
model_name='readthrough', model_name="readthrough",
old_name='pages_read', old_name="pages_read",
new_name='progress', new_name="progress",
), ),
migrations.AddField( migrations.AddField(
model_name='readthrough', model_name="readthrough",
name='progress_mode', name="progress_mode",
field=models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3), field=models.CharField(
choices=[("PG", "page"), ("PCT", "percent")], default="PG", max_length=3
),
), ),
] ]

View file

@ -5,58 +5,101 @@ from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0015_auto_20201128_0349'), ("bookwyrm", "0015_auto_20201128_0349"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='subject_places', name="subject_places",
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), field=ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
null=True,
size=None,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='subjects', name="subjects",
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), field=ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
null=True,
size=None,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='parent_work', name="parent_work",
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'), field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="editions",
to="bookwyrm.Work",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='tag', model_name="tag",
name='name', name="name",
field=models.CharField(max_length=100, unique=True), field=models.CharField(max_length=100, unique=True),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='tag', name="tag",
unique_together=set(), unique_together=set(),
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='tag', model_name="tag",
name='book', name="book",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='tag', model_name="tag",
name='user', name="user",
), ),
migrations.CreateModel( migrations.CreateModel(
name='UserTag', name="UserTag",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('created_date', models.DateTimeField(auto_now_add=True)), "id",
('updated_date', models.DateTimeField(auto_now=True)), models.AutoField(
('remote_id', models.CharField(max_length=255, null=True)), auto_created=True,
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')), primary_key=True,
('tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag')), serialize=False,
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
("remote_id", models.CharField(max_length=255, null=True)),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Edition",
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Tag"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'unique_together': {('user', 'book', 'tag')}, "unique_together": {("user", "book", "tag")},
}, },
), ),
] ]

View file

@ -6,23 +6,23 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0015_auto_20201128_0349'), ("bookwyrm", "0015_auto_20201128_0349"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='admin_email', name="admin_email",
field=models.EmailField(blank=True, max_length=255, null=True), field=models.EmailField(blank=True, max_length=255, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='support_link', name="support_link",
field=models.CharField(blank=True, max_length=255, null=True), field=models.CharField(blank=True, max_length=255, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='support_title', name="support_title",
field=models.CharField(blank=True, max_length=100, null=True), field=models.CharField(blank=True, max_length=100, null=True),
), ),
] ]

View file

@ -6,184 +6,296 @@ from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
def copy_rsa_keys(app_registry, schema_editor): def copy_rsa_keys(app_registry, schema_editor):
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
users = app_registry.get_model('bookwyrm', 'User') users = app_registry.get_model("bookwyrm", "User")
keypair = app_registry.get_model('bookwyrm', 'KeyPair') keypair = app_registry.get_model("bookwyrm", "KeyPair")
for user in users.objects.using(db_alias): for user in users.objects.using(db_alias):
if user.public_key or user.private_key: if user.public_key or user.private_key:
user.key_pair = keypair.objects.create( user.key_pair = keypair.objects.create(
remote_id='%s/#main-key' % user.remote_id, remote_id="%s/#main-key" % user.remote_id,
private_key=user.private_key, private_key=user.private_key,
public_key=user.public_key public_key=user.public_key,
) )
user.save() user.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0016_auto_20201129_0304'), ("bookwyrm", "0016_auto_20201129_0304"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='KeyPair', name="KeyPair",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('created_date', models.DateTimeField(auto_now_add=True)), "id",
('updated_date', models.DateTimeField(auto_now=True)), models.AutoField(
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])), auto_created=True,
('private_key', models.TextField(blank=True, null=True)), primary_key=True,
('public_key', bookwyrm.models.fields.TextField(blank=True, null=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],
),
),
("private_key", models.TextField(blank=True, null=True)),
("public_key", bookwyrm.models.fields.TextField(blank=True, null=True)),
], ],
options={ options={
'abstract': False, "abstract": False,
}, },
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model), bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='followers', name="followers",
field=bookwyrm.models.fields.ManyToManyField(related_name='following', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ManyToManyField(
related_name="following",
through="bookwyrm.UserFollows",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='connector', model_name="connector",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='favorite', model_name="favorite",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='federatedserver', model_name="federatedserver",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='image', model_name="image",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='notification', model_name="notification",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='readthrough', model_name="readthrough",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelf', model_name="shelf",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelfbook', model_name="shelfbook",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='tag', model_name="tag",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='avatar', name="avatar",
field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='avatars/'), field=bookwyrm.models.fields.ImageField(
blank=True, null=True, upload_to="avatars/"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='bookwyrm_user', name="bookwyrm_user",
field=bookwyrm.models.fields.BooleanField(default=True), field=bookwyrm.models.fields.BooleanField(default=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='inbox', name="inbox",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
unique=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='local', name="local",
field=models.BooleanField(default=False), field=models.BooleanField(default=False),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='manually_approves_followers', name="manually_approves_followers",
field=bookwyrm.models.fields.BooleanField(default=False), field=bookwyrm.models.fields.BooleanField(default=False),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='name', name="name",
field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=100, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='outbox', name="outbox",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
unique=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
unique=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='shared_inbox', name="shared_inbox",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='summary', name="summary",
field=bookwyrm.models.fields.TextField(blank=True, null=True), field=bookwyrm.models.fields.TextField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='username', name="username",
field=bookwyrm.models.fields.UsernameField(), field=bookwyrm.models.fields.UsernameField(),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userblocks', model_name="userblocks",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userfollowrequest', model_name="userfollowrequest",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userfollows', model_name="userfollows",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='usertag', model_name="usertag",
name='remote_id', name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]), field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
), ),
migrations.AddField( migrations.AddField(
model_name='user', model_name="user",
name='key_pair', name="key_pair",
field=bookwyrm.models.fields.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owner', to='bookwyrm.KeyPair'), field=bookwyrm.models.fields.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="owner",
to="bookwyrm.KeyPair",
),
), ),
migrations.RunPython(copy_rsa_keys), migrations.RunPython(copy_rsa_keys),
] ]

View file

@ -7,13 +7,15 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0016_auto_20201211_2026'), ("bookwyrm", "0016_auto_20201211_2026"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='readthrough', model_name="readthrough",
name='book', name="book",
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
),
), ),
] ]

View file

@ -6,20 +6,20 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0017_auto_20201130_1819'), ("bookwyrm", "0017_auto_20201130_1819"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='user', model_name="user",
name='following', name="following",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='user', model_name="user",
name='private_key', name="private_key",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='user', model_name="user",
name='public_key', name="public_key",
), ),
] ]

View file

@ -3,34 +3,36 @@
import bookwyrm.models.fields import bookwyrm.models.fields
from django.db import migrations from django.db import migrations
def update_notnull(app_registry, schema_editor): def update_notnull(app_registry, schema_editor):
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
users = app_registry.get_model('bookwyrm', 'User') users = app_registry.get_model("bookwyrm", "User")
for user in users.objects.using(db_alias): for user in users.objects.using(db_alias):
if user.name and user.summary: if user.name and user.summary:
continue continue
if not user.summary: if not user.summary:
user.summary = '' user.summary = ""
if not user.name: if not user.name:
user.name = '' user.name = ""
user.save() user.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0018_auto_20201130_1832'), ("bookwyrm", "0018_auto_20201130_1832"),
] ]
operations = [ operations = [
migrations.RunPython(update_notnull), migrations.RunPython(update_notnull),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='name', name="name",
field=bookwyrm.models.fields.CharField(default='', max_length=100), field=bookwyrm.models.fields.CharField(default="", max_length=100),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='summary', name="summary",
field=bookwyrm.models.fields.TextField(default=''), field=bookwyrm.models.fields.TextField(default=""),
), ),
] ]

View file

@ -11,343 +11,497 @@ import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0019_auto_20201130_1939'), ("bookwyrm", "0019_auto_20201130_1939"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='aliases', name="aliases",
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=bookwyrm.models.fields.ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
size=None,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='bio', name="bio",
field=bookwyrm.models.fields.TextField(blank=True, null=True), field=bookwyrm.models.fields.TextField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='born', name="born",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='died', name="died",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='name', name="name",
field=bookwyrm.models.fields.CharField(max_length=255), field=bookwyrm.models.fields.CharField(max_length=255),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='openlibrary_key', name="openlibrary_key",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='wikipedia_link', name="wikipedia_link",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='authors', name="authors",
field=bookwyrm.models.fields.ManyToManyField(to='bookwyrm.Author'), field=bookwyrm.models.fields.ManyToManyField(to="bookwyrm.Author"),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='cover', name="cover",
field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='covers/'), field=bookwyrm.models.fields.ImageField(
blank=True, null=True, upload_to="covers/"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='description', name="description",
field=bookwyrm.models.fields.TextField(blank=True, null=True), field=bookwyrm.models.fields.TextField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='first_published_date', name="first_published_date",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='goodreads_key', name="goodreads_key",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='languages', name="languages",
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=bookwyrm.models.fields.ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
size=None,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='librarything_key', name="librarything_key",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='openlibrary_key', name="openlibrary_key",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='published_date', name="published_date",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True), field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='series', name="series",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='series_number', name="series_number",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='sort_title', name="sort_title",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='subject_places', name="subject_places",
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), field=bookwyrm.models.fields.ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
null=True,
size=None,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='subjects', name="subjects",
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None), field=bookwyrm.models.fields.ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
null=True,
size=None,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='subtitle', name="subtitle",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='title', name="title",
field=bookwyrm.models.fields.CharField(max_length=255), field=bookwyrm.models.fields.CharField(max_length=255),
), ),
migrations.AlterField( migrations.AlterField(
model_name='boost', model_name="boost",
name='boosted_status', name="boosted_status",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="boosters",
to="bookwyrm.Status",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='comment', model_name="comment",
name='book', name="book",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='asin', name="asin",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='isbn_10', name="isbn_10",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='isbn_13', name="isbn_13",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='oclc_number', name="oclc_number",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='pages', name="pages",
field=bookwyrm.models.fields.IntegerField(blank=True, null=True), field=bookwyrm.models.fields.IntegerField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='parent_work', name="parent_work",
field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'), field=bookwyrm.models.fields.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="editions",
to="bookwyrm.Work",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='physical_format', name="physical_format",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='edition', model_name="edition",
name='publishers', name="publishers",
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None), field=bookwyrm.models.fields.ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
size=None,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='favorite', model_name="favorite",
name='status', name="status",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Status"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='favorite', model_name="favorite",
name='user', name="user",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='image', model_name="image",
name='caption', name="caption",
field=bookwyrm.models.fields.TextField(blank=True, null=True), field=bookwyrm.models.fields.TextField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='image', model_name="image",
name='image', name="image",
field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='status/'), field=bookwyrm.models.fields.ImageField(
blank=True, null=True, upload_to="status/"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='quotation', model_name="quotation",
name='book', name="book",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='quotation', model_name="quotation",
name='quote', name="quote",
field=bookwyrm.models.fields.TextField(), field=bookwyrm.models.fields.TextField(),
), ),
migrations.AlterField( migrations.AlterField(
model_name='review', model_name="review",
name='book', name="book",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='review', model_name="review",
name='name', name="name",
field=bookwyrm.models.fields.CharField(max_length=255, null=True), field=bookwyrm.models.fields.CharField(max_length=255, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='review', model_name="review",
name='rating', name="rating",
field=bookwyrm.models.fields.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]), field=bookwyrm.models.fields.IntegerField(
blank=True,
default=None,
null=True,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(5),
],
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelf', model_name="shelf",
name='name', name="name",
field=bookwyrm.models.fields.CharField(max_length=100), field=bookwyrm.models.fields.CharField(max_length=100),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelf', model_name="shelf",
name='privacy', name="privacy",
field=bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), field=bookwyrm.models.fields.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelf', model_name="shelf",
name='user', name="user",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelfbook', model_name="shelfbook",
name='added_by', name="added_by",
field=bookwyrm.models.fields.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelfbook', model_name="shelfbook",
name='book', name="book",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='shelfbook', model_name="shelfbook",
name='shelf', name="shelf",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Shelf'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Shelf"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='content', name="content",
field=bookwyrm.models.fields.TextField(blank=True, null=True), field=bookwyrm.models.fields.TextField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='mention_books', name="mention_books",
field=bookwyrm.models.fields.TagField(related_name='mention_book', to='bookwyrm.Edition'), field=bookwyrm.models.fields.TagField(
related_name="mention_book", to="bookwyrm.Edition"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='mention_users', name="mention_users",
field=bookwyrm.models.fields.TagField(related_name='mention_user', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.TagField(
related_name="mention_user", to=settings.AUTH_USER_MODEL
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='published_date', name="published_date",
field=bookwyrm.models.fields.DateTimeField(default=django.utils.timezone.now), field=bookwyrm.models.fields.DateTimeField(
default=django.utils.timezone.now
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='reply_parent', name="reply_parent",
field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'), field=bookwyrm.models.fields.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Status",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='sensitive', name="sensitive",
field=bookwyrm.models.fields.BooleanField(default=False), field=bookwyrm.models.fields.BooleanField(default=False),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='user', name="user",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='tag', model_name="tag",
name='name', name="name",
field=bookwyrm.models.fields.CharField(max_length=100, unique=True), field=bookwyrm.models.fields.CharField(max_length=100, unique=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userblocks', model_name="userblocks",
name='user_object', name="user_object",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="userblocks_user_object",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userblocks', model_name="userblocks",
name='user_subject', name="user_subject",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="userblocks_user_subject",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userfollowrequest', model_name="userfollowrequest",
name='user_object', name="user_object",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="userfollowrequest_user_object",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userfollowrequest', model_name="userfollowrequest",
name='user_subject', name="user_subject",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="userfollowrequest_user_subject",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userfollows', model_name="userfollows",
name='user_object', name="user_object",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="userfollows_user_object",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='userfollows', model_name="userfollows",
name='user_subject', name="user_subject",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="userfollows_user_subject",
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='usertag', model_name="usertag",
name='book', name="book",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='usertag', model_name="usertag",
name='tag', name="tag",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag'), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Tag"
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='usertag', model_name="usertag",
name='user', name="user",
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='work', model_name="work",
name='default_edition', name="default_edition",
field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'), field=bookwyrm.models.fields.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Edition",
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='work', model_name="work",
name='lccn', name="lccn",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
] ]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0020_auto_20201208_0213'), ("bookwyrm", "0020_auto_20201208_0213"),
('bookwyrm', '0016_auto_20201211_2026'), ("bookwyrm", "0016_auto_20201211_2026"),
] ]
operations = [ operations = []
]

View file

@ -5,26 +5,27 @@ from django.db import migrations
def set_author_name(app_registry, schema_editor): def set_author_name(app_registry, schema_editor):
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
authors = app_registry.get_model('bookwyrm', 'Author') authors = app_registry.get_model("bookwyrm", "Author")
for author in authors.objects.using(db_alias): for author in authors.objects.using(db_alias):
if not author.name: if not author.name:
author.name = '%s %s' % (author.first_name, author.last_name) author.name = "%s %s" % (author.first_name, author.last_name)
author.save() author.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0021_merge_20201212_1737'), ("bookwyrm", "0021_merge_20201212_1737"),
] ]
operations = [ operations = [
migrations.RunPython(set_author_name), migrations.RunPython(set_author_name),
migrations.RemoveField( migrations.RemoveField(
model_name='author', model_name="author",
name='first_name', name="first_name",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='author', model_name="author",
name='last_name', name="last_name",
), ),
] ]

View file

@ -7,13 +7,22 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0022_auto_20201212_1744'), ("bookwyrm", "0022_auto_20201212_1744"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='privacy', name="privacy",
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255), field=bookwyrm.models.fields.PrivacyField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
), ),
] ]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0017_auto_20201212_0059'), ("bookwyrm", "0017_auto_20201212_0059"),
('bookwyrm', '0022_auto_20201212_1744'), ("bookwyrm", "0022_auto_20201212_1744"),
] ]
operations = [ operations = []
]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0023_auto_20201214_0511'), ("bookwyrm", "0023_auto_20201214_0511"),
('bookwyrm', '0023_merge_20201216_0112'), ("bookwyrm", "0023_merge_20201216_0112"),
] ]
operations = [ operations = []
]

View file

@ -7,33 +7,33 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0024_merge_20201216_1721'), ("bookwyrm", "0024_merge_20201216_1721"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='bio', name="bio",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True), field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='book', model_name="book",
name='description', name="description",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True), field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='quotation', model_name="quotation",
name='quote', name="quote",
field=bookwyrm.models.fields.HtmlField(), field=bookwyrm.models.fields.HtmlField(),
), ),
migrations.AlterField( migrations.AlterField(
model_name='status', model_name="status",
name='content', name="content",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True), field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='summary', name="summary",
field=bookwyrm.models.fields.HtmlField(default=''), field=bookwyrm.models.fields.HtmlField(default=""),
), ),
] ]

View file

@ -7,13 +7,15 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0025_auto_20201217_0046'), ("bookwyrm", "0025_auto_20201217_0046"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='status', model_name="status",
name='content_warning', name="content_warning",
field=bookwyrm.models.fields.CharField(blank=True, max_length=500, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=500, null=True
),
), ),
] ]

View file

@ -7,18 +7,20 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0026_status_content_warning'), ("bookwyrm", "0026_status_content_warning"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='name', name="name",
field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=100, null=True
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='summary', name="summary",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True), field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
), ),
] ]

View file

@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0027_auto_20201220_2007'), ("bookwyrm", "0027_auto_20201220_2007"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='book', model_name="book",
name='author_text', name="author_text",
), ),
] ]

View file

@ -9,53 +9,65 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0028_remove_book_author_text'), ("bookwyrm", "0028_remove_book_author_text"),
] ]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(
model_name='author', model_name="author",
name='last_sync_date', name="last_sync_date",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='author', model_name="author",
name='sync', name="sync",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='book', model_name="book",
name='last_sync_date', name="last_sync_date",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='book', model_name="book",
name='sync', name="sync",
), ),
migrations.RemoveField( migrations.RemoveField(
model_name='book', model_name="book",
name='sync_cover', name="sync_cover",
), ),
migrations.AddField( migrations.AddField(
model_name='author', model_name="author",
name='goodreads_key', name="goodreads_key",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AddField( migrations.AddField(
model_name='author', model_name="author",
name='last_edited_by', name="last_edited_by",
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AddField( migrations.AddField(
model_name='author', model_name="author",
name='librarything_key', name="librarything_key",
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True), field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
), ),
migrations.AddField( migrations.AddField(
model_name='book', model_name="book",
name='last_edited_by', name="last_edited_by",
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='author', model_name="author",
name='origin_id', name="origin_id",
field=models.CharField(blank=True, max_length=255, null=True), field=models.CharField(blank=True, max_length=255, null=True),
), ),
] ]

View file

@ -7,13 +7,18 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0029_auto_20201221_2014'), ("bookwyrm", "0029_auto_20201221_2014"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='localname', name="localname",
field=models.CharField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_localname]), field=models.CharField(
max_length=255,
null=True,
unique=True,
validators=[bookwyrm.models.fields.validate_localname],
),
), ),
] ]

View file

@ -6,23 +6,23 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0030_auto_20201224_1939'), ("bookwyrm", "0030_auto_20201224_1939"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='favicon', name="favicon",
field=models.ImageField(blank=True, null=True, upload_to='logos/'), field=models.ImageField(blank=True, null=True, upload_to="logos/"),
), ),
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='logo', name="logo",
field=models.ImageField(blank=True, null=True, upload_to='logos/'), field=models.ImageField(blank=True, null=True, upload_to="logos/"),
), ),
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='logo_small', name="logo_small",
field=models.ImageField(blank=True, null=True, upload_to='logos/'), field=models.ImageField(blank=True, null=True, upload_to="logos/"),
), ),
] ]

View file

@ -6,18 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0031_auto_20210104_2040'), ("bookwyrm", "0031_auto_20210104_2040"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='instance_tagline', name="instance_tagline",
field=models.CharField(default='Social Reading and Reviewing', max_length=150), field=models.CharField(
default="Social Reading and Reviewing", max_length=150
),
), ),
migrations.AddField( migrations.AddField(
model_name='sitesettings', model_name="sitesettings",
name='registration_closed_text', name="registration_closed_text",
field=models.TextField(default='Contact an administrator to get an invite'), field=models.TextField(default="Contact an administrator to get an invite"),
), ),
] ]

View file

@ -7,14 +7,16 @@ import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0032_auto_20210104_2055'), ("bookwyrm", "0032_auto_20210104_2055"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='siteinvite', model_name="siteinvite",
name='created_date', name="created_date",
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False, preserve_default=False,
), ),
] ]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0033_siteinvite_created_date'), ("bookwyrm", "0033_siteinvite_created_date"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='importjob', model_name="importjob",
name='complete', name="complete",
field=models.BooleanField(default=False), field=models.BooleanField(default=False),
), ),
] ]

View file

@ -6,20 +6,21 @@ from django.db import migrations
def set_rank(app_registry, schema_editor): def set_rank(app_registry, schema_editor):
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
books = app_registry.get_model('bookwyrm', 'Edition') books = app_registry.get_model("bookwyrm", "Edition")
for book in books.objects.using(db_alias): for book in books.objects.using(db_alias):
book.save() book.save()
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0034_importjob_complete'), ("bookwyrm", "0034_importjob_complete"),
] ]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='edition', model_name="edition",
name='edition_rank', name="edition_rank",
field=bookwyrm.models.fields.IntegerField(default=0), field=bookwyrm.models.fields.IntegerField(default=0),
), ),
migrations.RunPython(set_rank), migrations.RunPython(set_rank),

View file

@ -9,24 +9,57 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0035_edition_edition_rank'), ("bookwyrm", "0035_edition_edition_rank"),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='AnnualGoal', name="AnnualGoal",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('created_date', models.DateTimeField(auto_now_add=True)), "id",
('updated_date', models.DateTimeField(auto_now=True)), models.AutoField(
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])), auto_created=True,
('goal', models.IntegerField()), primary_key=True,
('year', models.IntegerField(default=2021)), serialize=False,
('privacy', models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)), verbose_name="ID",
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ),
),
("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],
),
),
("goal", models.IntegerField()),
("year", models.IntegerField(default=2021)),
(
"privacy",
models.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
], ],
options={ options={
'unique_together': {('user', 'year')}, "unique_together": {("user", "year")},
}, },
), ),
] ]

View file

@ -2,36 +2,39 @@
from django.db import migrations, models from django.db import migrations, models
def empty_to_null(apps, schema_editor): def empty_to_null(apps, schema_editor):
User = apps.get_model("bookwyrm", "User") User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email="").update(email=None) User.objects.using(db_alias).filter(email="").update(email=None)
def null_to_empty(apps, schema_editor): def null_to_empty(apps, schema_editor):
User = apps.get_model("bookwyrm", "User") User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email=None).update(email="") User.objects.using(db_alias).filter(email=None).update(email="")
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0036_annualgoal'), ("bookwyrm", "0036_annualgoal"),
] ]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='shelfbook', name="shelfbook",
options={'ordering': ('-created_date',)}, options={"ordering": ("-created_date",)},
), ),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='email', name="email",
field=models.EmailField(max_length=254, null=True), field=models.EmailField(max_length=254, null=True),
), ),
migrations.RunPython(empty_to_null, null_to_empty), migrations.RunPython(empty_to_null, null_to_empty),
migrations.AlterField( migrations.AlterField(
model_name='user', model_name="user",
name='email', name="email",
field=models.EmailField(max_length=254, null=True, unique=True), field=models.EmailField(max_length=254, null=True, unique=True),
), ),
] ]

View file

@ -7,13 +7,15 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0037_auto_20210118_1954'), ("bookwyrm", "0037_auto_20210118_1954"),
] ]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='annualgoal', model_name="annualgoal",
name='goal', name="goal",
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1)]), field=models.IntegerField(
validators=[django.core.validators.MinValueValidator(1)]
),
), ),
] ]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('bookwyrm', '0038_auto_20210119_1534'), ("bookwyrm", "0038_auto_20210119_1534"),
('bookwyrm', '0015_auto_20201128_0734'), ("bookwyrm", "0015_auto_20201128_0734"),
] ]
operations = [ operations = []
]

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