mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-29 21:11:16 +00:00
Merge branch 'main' into inventaire
This commit is contained in:
commit
64c2313a5d
280 changed files with 20693 additions and 9991 deletions
|
@ -23,6 +23,7 @@ max_line_length = off
|
||||||
[*.{csv,json,html,md,po,py,svg,tsv}]
|
[*.{csv,json,html,md,po,py,svg,tsv}]
|
||||||
max_line_length = off
|
max_line_length = off
|
||||||
|
|
||||||
|
# ` ` at the end of a line is a line-break in markdown
|
||||||
[*.{md,markdown}]
|
[*.{md,markdown}]
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
@ -30,7 +31,9 @@ trim_trailing_whitespace = false
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
max_line_length = off
|
max_line_length = off
|
||||||
|
|
||||||
[{package.json,yarn.lock}]
|
# Computer generated files
|
||||||
|
[{package.json,*.lock,*.mo}]
|
||||||
indent_size = unset
|
indent_size = unset
|
||||||
indent_style = unset
|
indent_style = unset
|
||||||
max_line_length = unset
|
max_line_length = unset
|
||||||
|
insert_final_newline = unset
|
||||||
|
|
|
@ -5,6 +5,7 @@ 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
|
||||||
|
|
||||||
## 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]"
|
||||||
|
@ -26,13 +27,24 @@ POSTGRES_HOST=db
|
||||||
MAX_STREAM_LENGTH=200
|
MAX_STREAM_LENGTH=200
|
||||||
REDIS_ACTIVITY_HOST=redis_activity
|
REDIS_ACTIVITY_HOST=redis_activity
|
||||||
REDIS_ACTIVITY_PORT=6379
|
REDIS_ACTIVITY_PORT=6379
|
||||||
|
#REDIS_ACTIVITY_PASSWORD=redispassword345
|
||||||
|
|
||||||
# Celery config with redis broker
|
# Redis as celery broker
|
||||||
|
#REDIS_BROKER_PORT=6379
|
||||||
|
#REDIS_BROKER_PASSWORD=redispassword123
|
||||||
CELERY_BROKER=redis://redis_broker:6379/0
|
CELERY_BROKER=redis://redis_broker:6379/0
|
||||||
CELERY_RESULT_BACKEND=redis://redis_broker:6379/0
|
CELERY_RESULT_BACKEND=redis://redis_broker:6379/0
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Set this to true when initializing certbot for domain, false when not
|
||||||
|
CERTBOT_INIT=false
|
50
.env.prod.example
Normal file
50
.env.prod.example
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
## Leave unset to allow all hosts
|
||||||
|
# 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/
|
||||||
|
|
||||||
|
POSTGRES_PASSWORD=securedbpassword123
|
||||||
|
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
|
||||||
|
CELERY_BROKER=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Set this to true when initializing certbot for domain, false when not
|
||||||
|
CERTBOT_INIT=false
|
1
.eslintignore
Normal file
1
.eslintignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
**/vendor/**
|
82
.eslintrc.js
82
.eslintrc.js
|
@ -6,5 +6,85 @@ module.exports = {
|
||||||
"es6": true
|
"es6": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"extends": "eslint:recommended"
|
"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",
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
2
.github/workflows/black.yml
vendored
2
.github/workflows/black.yml
vendored
|
@ -8,6 +8,6 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v2
|
||||||
- uses: psf/black@20.8b1
|
- uses: psf/black@stable
|
||||||
with:
|
with:
|
||||||
args: ". --check -l 80 -S"
|
args: ". --check -l 80 -S"
|
||||||
|
|
19
.github/workflows/lint-frontend.yaml
vendored
19
.github/workflows/lint-frontend.yaml
vendored
|
@ -3,12 +3,14 @@ name: Lint Frontend
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, ci ]
|
branches: [ main, ci, frontend ]
|
||||||
paths:
|
paths:
|
||||||
- '.github/workflows/**'
|
- '.github/workflows/**'
|
||||||
- 'static/**'
|
- 'static/**'
|
||||||
|
- '.eslintrc'
|
||||||
|
- '.stylelintrc'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main, ci ]
|
branches: [ main, ci, frontend ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
|
@ -16,14 +18,21 @@ jobs:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
- name: Install modules
|
- name: Install modules
|
||||||
run: yarn
|
run: yarn
|
||||||
|
|
||||||
|
# See .stylelintignore for files that are not linted.
|
||||||
- name: Run stylelint
|
- name: Run stylelint
|
||||||
run: yarn stylelint **/static/**/*.css --report-needless-disables --report-invalid-scope-disables
|
run: >
|
||||||
|
yarn stylelint bookwyrm/static/**/*.css \
|
||||||
|
--report-needless-disables \
|
||||||
|
--report-invalid-scope-disables
|
||||||
|
|
||||||
|
# See .eslintignore for files that are not linted.
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
run: yarn eslint . --ext .js,.jsx,.ts,.tsx
|
run: >
|
||||||
|
yarn eslint bookwyrm/static \
|
||||||
|
--ext .js,.jsx,.ts,.tsx
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -24,3 +24,6 @@
|
||||||
|
|
||||||
#Node tools
|
#Node tools
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
|
||||||
|
#nginx
|
||||||
|
nginx/default.conf
|
||||||
|
|
|
@ -1,2 +1 @@
|
||||||
bookwyrm/static/css/bulma.*.css*
|
**/vendor/**
|
||||||
bookwyrm/static/css/icons.css
|
|
||||||
|
|
271
README.md
271
README.md
|
@ -9,29 +9,17 @@ Social reading and reviewing, decentralized with ActivityPub
|
||||||
- [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)
|
|
||||||
- [Installing in Production](#installing-in-production)
|
|
||||||
- [Book data](#book-data)
|
- [Book data](#book-data)
|
||||||
|
- [Set up Bookwyrm](#set-up-bookwyrm)
|
||||||
|
|
||||||
## 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 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 contributions 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 set up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
|
|
||||||
|
|
||||||
### Translation
|
|
||||||
Do you speak a language besides English? BookWyrm needs localization! If you're comfortable using git and want to get into the code, there are [instructions](#working-with-translations-and-locale-files) on how to create and edit localization files. If you feel more comfortable working in a regular text editor and would prefer not to run the application, get in touch directly and we can figure out a system, like emailing a text file, that works best.
|
|
||||||
|
|
||||||
### 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
|
||||||
|
@ -43,7 +31,7 @@ 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:
|
||||||
|
@ -73,8 +61,8 @@ Since the project is still in its early stages, the features are growing every d
|
||||||
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
|
- [Redis (again)](https://redis.io/) activity stream manager
|
||||||
|
|
||||||
|
@ -89,246 +77,9 @@ Deployment
|
||||||
- [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`
|
|
||||||
|
|
||||||
### Editing static files
|
|
||||||
If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` command in order for your changes to have effect. You can do this by running:
|
|
||||||
``` bash
|
|
||||||
./bw-dev collectstatic
|
|
||||||
```
|
|
||||||
|
|
||||||
### Working with translations and locale files
|
|
||||||
Text in the html files are wrapped in translation tags (`{% trans %}` and `{% blocktrans %}`), and Django generates locale files for all the strings in which you can add translations for the text. You can find existing translations in the `locale/` directory.
|
|
||||||
|
|
||||||
The application's language is set by a request header sent by your browser to the application, so to change the language of the application, you can change the default language requested by your browser.
|
|
||||||
|
|
||||||
#### Adding a locale
|
|
||||||
To start translation into a language which is currently supported, run the django-admin `makemessages` command with the language code for the language you want to add (like `de` for German, or `en-gb` for British English):
|
|
||||||
``` bash
|
|
||||||
./bw-dev makemessages -l <language code>
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Editing a locale
|
|
||||||
When you have a locale file, open the `django.po` in the directory for the language (for example, if you were adding German, `locale/de/LC_MESSAGES/django.po`. All the the text in the application will be shown in paired strings, with `msgid` as the original text, and `msgstr` as the translation (by default, this is set to an empty string, and will display the original text).
|
|
||||||
|
|
||||||
Add your translations to the `msgstr` strings. As the messages in the application are updated, `gettext` will sometimes add best-guess fuzzy matched options for those translations. When a message is marked as fuzzy, it will not be used in the application, so be sure to remove it when you translate that line.
|
|
||||||
|
|
||||||
When you're done, compile the locale by running:
|
|
||||||
|
|
||||||
``` bash
|
|
||||||
./bw-dev compilemessages
|
|
||||||
```
|
|
||||||
|
|
||||||
You can add the `-l <language code>` to only compile one language. When you refresh the application, you should see your translations at work.
|
|
||||||
|
|
||||||
## Installing in Production
|
|
||||||
|
|
||||||
This project is still young and isn't, at the moment, very stable, so please proceed 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 against Ubuntu 20.04)
|
|
||||||
- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings
|
|
||||||
- Install Docker and docker-compose
|
|
||||||
|
|
||||||
### Install and configure BookWyrm
|
|
||||||
|
|
||||||
The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and individual changes to container config to enable things like SSL or regular backups.
|
|
||||||
|
|
||||||
Instructions for running BookWyrm in production:
|
|
||||||
|
|
||||||
- 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, SMTP 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) with
|
|
||||||
`docker-compose up --build`, and 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 with: `docker-compose up -d`
|
|
||||||
- Initialize the database with: `./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 location
|
|
||||||
- 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, SMTP 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
|
|
||||||
- If you aren't using the `www` subdomain, remove the www.your-domain.com version of the domain from the `server_name` in the first server block in `nginx/default.conf` and remove the `-d www.${DOMAIN}` flag at the end of the `certbot` command in `docker-compose.yml`.
|
|
||||||
- If you are running another web-server on your host machine, you will need to follow the [reverse-proxy instructions](#running-bookwyrm-behind-a-reverse-proxy)
|
|
||||||
- Run the application (this should also set up a Certbot ssl cert for your domain) with
|
|
||||||
`docker-compose up --build`, and make sure all the images build successfully
|
|
||||||
- If you are running other services on your host machine, you may run into errors where services fail when attempting to bind to a port.
|
|
||||||
See the [troubleshooting guide](#port-conflicts) for advice on resolving this.
|
|
||||||
- When docker has built successfully, stop the process with `CTRL-C`
|
|
||||||
- Comment out the `command: certonly...` line in `docker-compose.yml`, and uncomment the following line (`command: renew ...`) so that the certificate will be automatically renewed.
|
|
||||||
- Uncomment the https redirect and `server` block in `nginx/default.conf` (lines 17-48).
|
|
||||||
- Run docker-compose in the background with: `docker-compose up -d`
|
|
||||||
- Initialize the database with: `./bw-dev initdb`
|
|
||||||
|
|
||||||
Congrats! You did it, go to your domain and enjoy the fruits of your labors.
|
|
||||||
|
|
||||||
### Configure your instance
|
|
||||||
- Register a user account in the application 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
|
|
||||||
|
|
||||||
### Backups
|
|
||||||
|
|
||||||
BookWyrm's db service dumps a backup copy of its database to its `/backups` directory daily at midnight UTC.
|
|
||||||
Backups are named `backup__%Y-%m-%d.sql`.
|
|
||||||
|
|
||||||
The db service has an optional script for periodically pruning the backups directory so that all recent daily backups are kept, but for older backups, only weekly or monthly backups are kept.
|
|
||||||
To enable this script:
|
|
||||||
- Uncomment the final line in `postgres-docker/cronfile`
|
|
||||||
- rebuild your instance `docker-compose up --build`
|
|
||||||
|
|
||||||
You can copy backups from the backups volume to your host machine with `docker cp`:
|
|
||||||
- Run `docker-compose ps` to confirm the db service's full name (it's probably `bookwyrm_db_1`.
|
|
||||||
- Run `docker cp <container_name>:/backups <host machine path>`
|
|
||||||
|
|
||||||
### Updating your instance
|
|
||||||
|
|
||||||
When there are changes available in the production branch, you can install and get them running on your instance using the command `./bw-dev update`. This does a number of things:
|
|
||||||
- `git pull` gets the updated code from the git repository. If there are conflicts, you may need to run `git pull` separately and resolve the conflicts before trying the `./bw-dev update` script again.
|
|
||||||
- `docker-compose build` rebuilds the images, which ensures that the correct packages are installed. This step takes a long time and is only needed when the dependencies (including pip `requirements.txt` packages) have changed, so you can comment it out if you want a quicker update path and don't mind un-commenting it as needed.
|
|
||||||
- `docker-compose exec web python manage.py migrate` runs the database migrations in Django
|
|
||||||
- `docker-compose exec web python manage.py collectstatic --no-input` loads any updated static files (such as the JavaScript and CSS)
|
|
||||||
- `docker-compose restart` reloads the docker containers
|
|
||||||
|
|
||||||
### Re-building activity streams
|
|
||||||
|
|
||||||
If something goes awry with user timelines, and you want to re-create them en mass, there's a management command for that:
|
|
||||||
`docker-compose run --rm web python manage.py rebuild_feeds`
|
|
||||||
|
|
||||||
### Port Conflicts
|
|
||||||
|
|
||||||
BookWyrm has multiple services that run on their default ports.
|
|
||||||
This means that, depending on what else you are running on your host machine, you may run into errors when building or running BookWyrm when attempts to bind to those ports fail.
|
|
||||||
|
|
||||||
If this occurs, you will need to change your configuration to run services on different ports.
|
|
||||||
This may require one or more changes the following files:
|
|
||||||
- `docker-compose.yml`
|
|
||||||
- `nginx/default.conf`
|
|
||||||
- `.env` (You create this file yourself during setup)
|
|
||||||
|
|
||||||
E.g., If you need Redis to run on a different port:
|
|
||||||
- In `docker-compose.yml`:
|
|
||||||
- In `services` -> `redis` -> `command`, add `--port YOUR_PORT` to the command
|
|
||||||
- In `services` -> `redis` -> `ports`, change `6379:6379` to your port
|
|
||||||
- In `.env`, update `REDIS_PORT`
|
|
||||||
|
|
||||||
If you are already running a web-server on your machine, you will need to set up a reverse-proxy.
|
|
||||||
|
|
||||||
#### Running BookWyrm Behind a Reverse-Proxy
|
|
||||||
|
|
||||||
If you are running another web-server on your machine, you should have it handle proxying web requests to BookWyrm.
|
|
||||||
|
|
||||||
The default BookWyrm configuration already has an nginx server that proxies requests to the django app that handles SSL and directly serves static files.
|
|
||||||
The static files are stored in a Docker volume that several BookWyrm services access, so it is not recommended to remove this server completely.
|
|
||||||
|
|
||||||
To run BookWyrm behind a reverse-proxy, make the following changes:
|
|
||||||
- In `nginx/default.conf`:
|
|
||||||
- Comment out the two default servers
|
|
||||||
- Uncomment the server labeled Reverse-Proxy server
|
|
||||||
- Replace `your-domain.com` with your domain name
|
|
||||||
- In `docker-compose.yml`:
|
|
||||||
- In `services` -> `nginx` -> `ports`, comment out the default ports and add `- 8001:8001`
|
|
||||||
- In `services` -> `nginx` -> `volumes`, comment out the two volumes that begin `./certbot/`
|
|
||||||
- In `services`, comment out the `certbot` service
|
|
||||||
|
|
||||||
At this point, you can follow, the [setup](#server-setup) instructions as listed.
|
|
||||||
Once docker is running, you can access your BookWyrm instance at `http://localhost:8001` (**NOTE:** your server is not accessible over `https`).
|
|
||||||
|
|
||||||
Steps for setting up a reverse-proxy are server dependent.
|
|
||||||
|
|
||||||
##### Nginx
|
|
||||||
|
|
||||||
Before you can set up nginx, you will need to locate your nginx configuration directory, which is dependent on your platform and how you installed nginx.
|
|
||||||
See [nginx's guide](http://nginx.org/en/docs/beginners_guide.html) for details.
|
|
||||||
|
|
||||||
To set up your server:
|
|
||||||
- In you `nginx.conf` file, ensure that `include servers/*;` isn't commented out.
|
|
||||||
- In your nginx `servers` directory, create a new file named after your domain containing the following information:
|
|
||||||
```nginx
|
|
||||||
server {
|
|
||||||
server_name your-domain.com www.your-domain.com;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://localhost:8000;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /images/ {
|
|
||||||
proxy_pass http://localhost:8001;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /static/ {
|
|
||||||
proxy_pass http://localhost:8001;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
|
|
||||||
listen [::]:80 ssl;
|
|
||||||
listen 80 ssl;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- run `sudo certbot run --nginx --email YOUR_EMAIL -d your-domain.com -d www.your-domain.com`
|
|
||||||
- restart nginx
|
|
||||||
|
|
||||||
If everything worked correctly, your BookWyrm instance should now be externally accessible.
|
|
||||||
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
|
@ -5,11 +5,12 @@ import sys
|
||||||
from .base_activity import ActivityEncoder, Signature, naive_parse
|
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, Quotation
|
from .note import Note, GeneratedNote, Article, Comment, Quotation
|
||||||
from .note import Review, Rating
|
from .note import Review, Rating
|
||||||
from .note import Tombstone
|
from .note import Tombstone
|
||||||
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
|
||||||
|
@ -26,5 +27,5 @@ activity_objects = {c[0]: c[1] for c in cls_members if hasattr(c[1], "to_model")
|
||||||
|
|
||||||
|
|
||||||
def parse(activity_json):
|
def parse(activity_json):
|
||||||
""" figure out what activity this is and parse it """
|
"""figure out what activity this is and parse it"""
|
||||||
return naive_parse(activity_objects, activity_json)
|
return naive_parse(activity_objects, activity_json)
|
||||||
|
|
|
@ -10,11 +10,11 @@ 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__
|
||||||
|
@ -22,7 +22,7 @@ class ActivityEncoder(JSONEncoder):
|
||||||
|
|
||||||
@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
|
||||||
|
@ -31,14 +31,14 @@ class 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
|
||||||
|
@ -47,15 +47,19 @@ class Signature:
|
||||||
|
|
||||||
|
|
||||||
def naive_parse(activity_objects, activity_json, serializer=None):
|
def naive_parse(activity_objects, activity_json, serializer=None):
|
||||||
""" this navigates circular import issues """
|
"""this navigates circular import issues"""
|
||||||
if not serializer:
|
if not serializer:
|
||||||
if activity_json.get("publicKeyPem"):
|
if activity_json.get("publicKeyPem"):
|
||||||
# ugh
|
# ugh
|
||||||
activity_json["type"] = "PublicKey"
|
activity_json["type"] = "PublicKey"
|
||||||
|
|
||||||
|
activity_type = activity_json.get("type")
|
||||||
try:
|
try:
|
||||||
activity_type = activity_json["type"]
|
|
||||||
serializer = activity_objects[activity_type]
|
serializer = activity_objects[activity_type]
|
||||||
except KeyError as e:
|
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)
|
raise ActivitySerializerError(e)
|
||||||
|
|
||||||
return serializer(activity_objects=activity_objects, **activity_json)
|
return serializer(activity_objects=activity_objects, **activity_json)
|
||||||
|
@ -63,7 +67,7 @@ def naive_parse(activity_objects, activity_json, serializer=None):
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class ActivityObject:
|
class ActivityObject:
|
||||||
""" actor activitypub json """
|
"""actor activitypub json"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
type: str
|
type: str
|
||||||
|
@ -102,7 +106,7 @@ class ActivityObject:
|
||||||
setattr(self, field.name, value)
|
setattr(self, field.name, value)
|
||||||
|
|
||||||
def to_model(self, model=None, instance=None, allow_create=True, save=True):
|
def to_model(self, model=None, instance=None, allow_create=True, save=True):
|
||||||
""" convert from an activity to a model instance """
|
"""convert from an activity to a model instance"""
|
||||||
model = model or get_model_from_type(self.type)
|
model = model or get_model_from_type(self.type)
|
||||||
|
|
||||||
# only reject statuses if we're potentially creating them
|
# only reject statuses if we're potentially creating them
|
||||||
|
@ -111,7 +115,7 @@ class ActivityObject:
|
||||||
and hasattr(model, "ignore_activity")
|
and hasattr(model, "ignore_activity")
|
||||||
and model.ignore_activity(self)
|
and model.ignore_activity(self)
|
||||||
):
|
):
|
||||||
raise ActivitySerializerError()
|
return None
|
||||||
|
|
||||||
# check for an existing instance
|
# check for an existing instance
|
||||||
instance = instance or model.find_existing(self.serialize())
|
instance = instance or model.find_existing(self.serialize())
|
||||||
|
@ -177,7 +181,7 @@ class ActivityObject:
|
||||||
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__.copy()
|
data = self.__dict__.copy()
|
||||||
# recursively serialize
|
# recursively serialize
|
||||||
for (k, v) in data.items():
|
for (k, v) in data.items():
|
||||||
|
@ -196,7 +200,7 @@ class ActivityObject:
|
||||||
def set_related_field(
|
def set_related_field(
|
||||||
model_name, origin_model_name, related_field_name, related_remote_id, data
|
model_name, origin_model_name, related_field_name, 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("bookwyrm.%s" % origin_model_name, require_ready=True)
|
origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
|
||||||
|
|
||||||
|
@ -232,7 +236,7 @@ def set_related_field(
|
||||||
|
|
||||||
|
|
||||||
def get_model_from_type(activity_type):
|
def get_model_from_type(activity_type):
|
||||||
""" given the activity, what type of model """
|
"""given the activity, what type of model"""
|
||||||
models = apps.get_models()
|
models = apps.get_models()
|
||||||
model = [
|
model = [
|
||||||
m
|
m
|
||||||
|
@ -251,7 +255,7 @@ def get_model_from_type(activity_type):
|
||||||
def resolve_remote_id(
|
def resolve_remote_id(
|
||||||
remote_id, model=None, refresh=False, save=True, get_activity=False
|
remote_id, model=None, refresh=False, save=True, get_activity=False
|
||||||
):
|
):
|
||||||
""" take a remote_id and return an instance, creating if necessary """
|
"""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
|
if model: # a bonus check we can do if we already know the model
|
||||||
result = model.find_existing_by_remote_id(remote_id)
|
result = model.find_existing_by_remote_id(remote_id)
|
||||||
if result and not refresh:
|
if result and not refresh:
|
||||||
|
|
|
@ -3,7 +3,7 @@ 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)
|
||||||
|
@ -15,11 +15,12 @@ class BookData(ActivityObject):
|
||||||
librarythingKey: str = None
|
librarythingKey: str = None
|
||||||
goodreadsKey: str = None
|
goodreadsKey: str = None
|
||||||
bnfId: str = None
|
bnfId: str = None
|
||||||
|
lastEditedBy: str = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Book(BookData):
|
class Book(BookData):
|
||||||
""" serializes an edition or work, abstract """
|
"""serializes an edition or work, abstract"""
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
sortTitle: str = ""
|
sortTitle: str = ""
|
||||||
|
@ -35,13 +36,13 @@ class Book(BookData):
|
||||||
firstPublishedDate: str = ""
|
firstPublishedDate: str = ""
|
||||||
publishedDate: str = ""
|
publishedDate: str = ""
|
||||||
|
|
||||||
cover: Image = None
|
cover: Document = None
|
||||||
type: str = "Book"
|
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 = ""
|
||||||
|
@ -58,7 +59,7 @@ class Edition(Book):
|
||||||
|
|
||||||
@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 = ""
|
lccn: str = ""
|
||||||
defaultEdition: str = ""
|
defaultEdition: str = ""
|
||||||
|
@ -68,7 +69,7 @@ class Work(Book):
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Author(BookData):
|
class Author(BookData):
|
||||||
""" author of a book """
|
"""author of a book"""
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
isni: str = None
|
isni: str = None
|
||||||
|
|
|
@ -4,10 +4,17 @@ 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 = "Document"
|
type: str = "Document"
|
||||||
id: str = None
|
id: str = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(init=False)
|
||||||
|
class Image(Document):
|
||||||
|
"""an image"""
|
||||||
|
|
||||||
|
type: str = "Image"
|
||||||
|
|
|
@ -4,24 +4,24 @@ from typing import Dict, List
|
||||||
from django.apps import apps
|
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"""
|
||||||
|
|
||||||
type: str = "Tombstone"
|
type: str = "Tombstone"
|
||||||
|
|
||||||
def to_model(self, *args, **kwargs): # pylint: disable=unused-argument
|
def to_model(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||||
""" this should never really get serialized, just searched for """
|
"""this should never really get serialized, just searched for"""
|
||||||
model = apps.get_model("bookwyrm.Status")
|
model = apps.get_model("bookwyrm.Status")
|
||||||
return model.find_existing_by_remote_id(self.id)
|
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
|
||||||
|
@ -32,14 +32,14 @@ class Note(ActivityObject):
|
||||||
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"
|
||||||
|
@ -47,14 +47,14 @@ class Article(Note):
|
||||||
|
|
||||||
@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"
|
||||||
|
@ -62,7 +62,7 @@ class Comment(Note):
|
||||||
|
|
||||||
@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"
|
||||||
|
@ -70,7 +70,7 @@ class Quotation(Comment):
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Review(Comment):
|
class Review(Comment):
|
||||||
""" a full book review """
|
"""a full book review"""
|
||||||
|
|
||||||
name: str = None
|
name: str = None
|
||||||
rating: int = None
|
rating: int = None
|
||||||
|
@ -79,7 +79,7 @@ class Review(Comment):
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Rating(Comment):
|
class Rating(Comment):
|
||||||
""" just a star rating """
|
"""just a star rating"""
|
||||||
|
|
||||||
rating: int
|
rating: int
|
||||||
content: str = None
|
content: str = None
|
||||||
|
|
|
@ -7,7 +7,7 @@ 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
|
||||||
|
@ -19,7 +19,7 @@ class OrderedCollection(ActivityObject):
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class OrderedCollectionPrivate(OrderedCollection):
|
class OrderedCollectionPrivate(OrderedCollection):
|
||||||
""" an ordered collection with privacy settings """
|
"""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: [])
|
||||||
|
@ -27,14 +27,14 @@ class OrderedCollectionPrivate(OrderedCollection):
|
||||||
|
|
||||||
@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"
|
||||||
|
@ -43,10 +43,37 @@ class BookList(OrderedCollectionPrivate):
|
||||||
|
|
||||||
@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 = None
|
next: str = None
|
||||||
prev: str = None
|
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"
|
||||||
|
|
|
@ -8,7 +8,7 @@ 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
|
||||||
|
@ -17,12 +17,13 @@ class PublicKey(ActivityObject):
|
||||||
|
|
||||||
@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
|
||||||
publicKey: PublicKey
|
publicKey: PublicKey
|
||||||
followers: str = None
|
followers: str = None
|
||||||
|
following: str = None
|
||||||
outbox: str = None
|
outbox: str = None
|
||||||
endpoints: Dict = None
|
endpoints: Dict = None
|
||||||
name: str = None
|
name: str = None
|
||||||
|
|
|
@ -1,69 +1,83 @@
|
||||||
""" undo wrapper activity """
|
""" activities that do things """
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List
|
from typing import List
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
|
||||||
from .base_activity import ActivityObject, Signature, resolve_remote_id
|
from .base_activity import ActivityObject, Signature, resolve_remote_id
|
||||||
from .book import Edition
|
from .ordered_collection import CollectionItem
|
||||||
|
|
||||||
|
|
||||||
@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):
|
def action(self):
|
||||||
""" usually we just want to save, this can be overridden as needed """
|
"""usually we just want to update and save"""
|
||||||
self.object.to_model()
|
# 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
|
to: List[str]
|
||||||
cc: List
|
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
|
to: List[str]
|
||||||
cc: List
|
cc: List[str] = field(default_factory=lambda: [])
|
||||||
type: str = "Delete"
|
type: str = "Delete"
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" find and delete the activity object """
|
"""find and delete the activity object"""
|
||||||
obj = self.object.to_model(save=False, allow_create=False)
|
if not self.object:
|
||||||
obj.delete()
|
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
|
to: List[str]
|
||||||
type: str = "Update"
|
type: str = "Update"
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" update a model instance from the dataclass """
|
"""update a model instance from the dataclass"""
|
||||||
self.object.to_model(allow_create=False)
|
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):
|
def action(self):
|
||||||
""" find and remove the activity object """
|
"""find and remove the activity object"""
|
||||||
if isinstance(self.object, str):
|
if isinstance(self.object, str):
|
||||||
# it may be that sometihng should be done with these, but idk what
|
# it may be that sometihng should be done with these, but idk what
|
||||||
# this seems just to be coming from pleroma
|
# this seems just to be coming from pleroma
|
||||||
|
@ -89,107 +103,98 @@ class Undo(Verb):
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Follow(Verb):
|
class Follow(Verb):
|
||||||
""" Follow activity """
|
"""Follow activity"""
|
||||||
|
|
||||||
object: str
|
object: str
|
||||||
type: str = "Follow"
|
type: str = "Follow"
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" relationship save """
|
"""relationship save"""
|
||||||
self.to_model()
|
self.to_model()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Block(Verb):
|
class Block(Verb):
|
||||||
""" Block activity """
|
"""Block activity"""
|
||||||
|
|
||||||
object: str
|
object: str
|
||||||
type: str = "Block"
|
type: str = "Block"
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" relationship save """
|
"""relationship save"""
|
||||||
self.to_model()
|
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):
|
def action(self):
|
||||||
""" find and remove the activity object """
|
"""find and remove the activity object"""
|
||||||
obj = self.object.to_model(save=False, allow_create=False)
|
obj = self.object.to_model(save=False, allow_create=False)
|
||||||
obj.accept()
|
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):
|
def action(self):
|
||||||
""" find and remove the activity object """
|
"""find and remove the activity object"""
|
||||||
obj = self.object.to_model(save=False, allow_create=False)
|
obj = self.object.to_model(save=False, allow_create=False)
|
||||||
obj.reject()
|
obj.reject()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Add(Verb):
|
class Add(Verb):
|
||||||
"""Add activity """
|
"""Add activity"""
|
||||||
|
|
||||||
target: str
|
target: ActivityObject
|
||||||
object: Edition
|
object: CollectionItem
|
||||||
type: str = "Add"
|
type: str = "Add"
|
||||||
notes: str = None
|
|
||||||
order: int = 0
|
|
||||||
approved: bool = True
|
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" add obj to collection """
|
"""figure out the target to assign the item to a collection"""
|
||||||
target = resolve_remote_id(self.target, refresh=False)
|
target = resolve_remote_id(self.target)
|
||||||
# we want to get the related field that isn't the book, this is janky af sorry
|
item = self.object.to_model(save=False)
|
||||||
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
|
setattr(item, item.collection_field, target)
|
||||||
0
|
item.save()
|
||||||
].related_model
|
|
||||||
self.to_model(model=model)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Remove(Verb):
|
class Remove(Add):
|
||||||
"""Remove activity """
|
"""Remove activity"""
|
||||||
|
|
||||||
target: ActivityObject
|
|
||||||
type: str = "Remove"
|
type: str = "Remove"
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" find and remove the activity object """
|
"""find and remove the activity object"""
|
||||||
target = resolve_remote_id(self.target, refresh=False)
|
obj = self.object.to_model(save=False, allow_create=False)
|
||||||
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
|
if obj:
|
||||||
0
|
obj.delete()
|
||||||
].related_model
|
|
||||||
obj = self.to_model(model=model, save=False, allow_create=False)
|
|
||||||
obj.delete()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Like(Verb):
|
class Like(Verb):
|
||||||
""" a user faving an object """
|
"""a user faving an object"""
|
||||||
|
|
||||||
object: str
|
object: str
|
||||||
type: str = "Like"
|
type: str = "Like"
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" like """
|
"""like"""
|
||||||
self.to_model()
|
self.to_model()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
class Announce(Verb):
|
class Announce(Verb):
|
||||||
""" boosting a status """
|
"""boosting a status"""
|
||||||
|
|
||||||
published: str
|
published: str
|
||||||
to: List[str] = field(default_factory=lambda: [])
|
to: List[str] = field(default_factory=lambda: [])
|
||||||
|
@ -198,5 +203,5 @@ class Announce(Verb):
|
||||||
type: str = "Announce"
|
type: str = "Announce"
|
||||||
|
|
||||||
def action(self):
|
def action(self):
|
||||||
""" boost """
|
"""boost"""
|
||||||
self.to_model()
|
self.to_model()
|
||||||
|
|
|
@ -1,80 +1,57 @@
|
||||||
""" access the activity streams stored in redis """
|
""" access the activity streams stored in redis """
|
||||||
from abc import ABC
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db.models import signals, Q
|
from django.db.models import signals, Q
|
||||||
import redis
|
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models
|
||||||
|
from bookwyrm.redis_store import RedisStore, r
|
||||||
from bookwyrm.views.helpers import privacy_filter
|
from bookwyrm.views.helpers import privacy_filter
|
||||||
|
|
||||||
r = redis.Redis(
|
|
||||||
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
class ActivityStream(RedisStore):
|
||||||
class ActivityStream(ABC):
|
"""a category of activity stream (like home, local, federated)"""
|
||||||
""" a category of activity stream (like home, local, federated) """
|
|
||||||
|
|
||||||
def stream_id(self, user):
|
def stream_id(self, user):
|
||||||
""" the redis key for this user's instance of this stream """
|
"""the redis key for this user's instance of this stream"""
|
||||||
return "{}-{}".format(user.id, self.key)
|
return "{}-{}".format(user.id, self.key)
|
||||||
|
|
||||||
def unread_id(self, user):
|
def unread_id(self, user):
|
||||||
""" the redis key for this user's unread count for this stream """
|
"""the redis key for this user's unread count for this stream"""
|
||||||
return "{}-unread".format(self.stream_id(user))
|
return "{}-unread".format(self.stream_id(user))
|
||||||
|
|
||||||
def get_value(self, status): # pylint: disable=no-self-use
|
def get_rank(self, obj): # pylint: disable=no-self-use
|
||||||
""" the status id and the rank (ie, published date) """
|
"""statuses are sorted by date published"""
|
||||||
return {status.id: status.published_date.timestamp()}
|
return obj.published_date.timestamp()
|
||||||
|
|
||||||
def add_status(self, status):
|
def add_status(self, status):
|
||||||
""" add a status to users' feeds """
|
"""add a status to users' feeds"""
|
||||||
value = self.get_value(status)
|
# the pipeline contains all the add-to-stream activities
|
||||||
# we want to do this as a bulk operation, hence "pipeline"
|
pipeline = self.add_object_to_related_stores(status, execute=False)
|
||||||
pipeline = r.pipeline()
|
|
||||||
for user in self.stream_users(status):
|
for user in self.get_audience(status):
|
||||||
# add the status to the feed
|
|
||||||
pipeline.zadd(self.stream_id(user), value)
|
|
||||||
pipeline.zremrangebyrank(
|
|
||||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
|
||||||
)
|
|
||||||
# add to the unread status count
|
# add to the unread status count
|
||||||
pipeline.incr(self.unread_id(user))
|
pipeline.incr(self.unread_id(user))
|
||||||
|
|
||||||
# and go!
|
# and go!
|
||||||
pipeline.execute()
|
pipeline.execute()
|
||||||
|
|
||||||
def remove_status(self, status):
|
|
||||||
""" remove a status from all feeds """
|
|
||||||
pipeline = r.pipeline()
|
|
||||||
for user in self.stream_users(status):
|
|
||||||
pipeline.zrem(self.stream_id(user), -1, status.id)
|
|
||||||
pipeline.execute()
|
|
||||||
|
|
||||||
def add_user_statuses(self, viewer, user):
|
def add_user_statuses(self, viewer, user):
|
||||||
""" add a user's statuses to another user's feed """
|
"""add a user's statuses to another user's feed"""
|
||||||
pipeline = r.pipeline()
|
# only add the statuses that the viewer should be able to see (ie, not dms)
|
||||||
statuses = user.status_set.all()[: settings.MAX_STREAM_LENGTH]
|
statuses = privacy_filter(viewer, user.status_set.all())
|
||||||
for status in statuses:
|
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
|
||||||
pipeline.zadd(self.stream_id(viewer), self.get_value(status))
|
|
||||||
if statuses:
|
|
||||||
pipeline.zremrangebyrank(
|
|
||||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
|
||||||
)
|
|
||||||
pipeline.execute()
|
|
||||||
|
|
||||||
def remove_user_statuses(self, viewer, user):
|
def remove_user_statuses(self, viewer, user):
|
||||||
""" remove a user's status from another user's feed """
|
"""remove a user's status from another user's feed"""
|
||||||
pipeline = r.pipeline()
|
# remove all so that followers only statuses are removed
|
||||||
for status in user.status_set.all()[: settings.MAX_STREAM_LENGTH]:
|
statuses = user.status_set.all()
|
||||||
pipeline.lrem(self.stream_id(viewer), -1, status.id)
|
self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
|
||||||
pipeline.execute()
|
|
||||||
|
|
||||||
def get_activity_stream(self, user):
|
def get_activity_stream(self, user):
|
||||||
""" load the ids for statuses to be displayed """
|
"""load the statuses to be displayed"""
|
||||||
# clear unreads for this feed
|
# clear unreads for this feed
|
||||||
r.set(self.unread_id(user), 0)
|
r.set(self.unread_id(user), 0)
|
||||||
|
|
||||||
statuses = r.zrevrange(self.stream_id(user), 0, -1)
|
statuses = self.get_store(self.stream_id(user))
|
||||||
return (
|
return (
|
||||||
models.Status.objects.select_subclasses()
|
models.Status.objects.select_subclasses()
|
||||||
.filter(id__in=statuses)
|
.filter(id__in=statuses)
|
||||||
|
@ -82,27 +59,15 @@ class ActivityStream(ABC):
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_unread_count(self, user):
|
def get_unread_count(self, user):
|
||||||
""" get the unread status count for this user's feed """
|
"""get the unread status count for this user's feed"""
|
||||||
return int(r.get(self.unread_id(user)) or 0)
|
return int(r.get(self.unread_id(user)) or 0)
|
||||||
|
|
||||||
def populate_stream(self, user):
|
def populate_streams(self, user):
|
||||||
""" go from zero to a timeline """
|
"""go from zero to a timeline"""
|
||||||
pipeline = r.pipeline()
|
self.populate_store(self.stream_id(user))
|
||||||
statuses = self.stream_statuses(user)
|
|
||||||
|
|
||||||
stream_id = self.stream_id(user)
|
def get_audience(self, status): # pylint: disable=no-self-use
|
||||||
for status in statuses.all()[: settings.MAX_STREAM_LENGTH]:
|
"""given a status, what users should see it"""
|
||||||
pipeline.zadd(stream_id, self.get_value(status))
|
|
||||||
|
|
||||||
# only trim the stream if statuses were added
|
|
||||||
if statuses.exists():
|
|
||||||
pipeline.zremrangebyrank(
|
|
||||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
|
||||||
)
|
|
||||||
pipeline.execute()
|
|
||||||
|
|
||||||
def stream_users(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
|
# direct messages don't appeard in feeds, direct comments/reviews/etc do
|
||||||
if status.privacy == "direct" and status.status_type == "Note":
|
if status.privacy == "direct" and status.status_type == "Note":
|
||||||
return []
|
return []
|
||||||
|
@ -129,22 +94,29 @@ class ActivityStream(ABC):
|
||||||
)
|
)
|
||||||
return audience.distinct()
|
return audience.distinct()
|
||||||
|
|
||||||
def stream_statuses(self, user): # pylint: disable=no-self-use
|
def get_stores_for_object(self, obj):
|
||||||
""" given a user, what statuses should they see on this stream """
|
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(
|
return privacy_filter(
|
||||||
user,
|
user,
|
||||||
models.Status.objects.select_subclasses(),
|
models.Status.objects.select_subclasses(),
|
||||||
privacy_levels=["public", "unlisted", "followers"],
|
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):
|
class HomeStream(ActivityStream):
|
||||||
""" users you follow """
|
"""users you follow"""
|
||||||
|
|
||||||
key = "home"
|
key = "home"
|
||||||
|
|
||||||
def stream_users(self, status):
|
def get_audience(self, status):
|
||||||
audience = super().stream_users(status)
|
audience = super().get_audience(status)
|
||||||
if not audience:
|
if not audience:
|
||||||
return []
|
return []
|
||||||
return audience.filter(
|
return audience.filter(
|
||||||
|
@ -152,7 +124,7 @@ class HomeStream(ActivityStream):
|
||||||
| Q(following=status.user) # if the user is following the author
|
| Q(following=status.user) # if the user is following the author
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
def stream_statuses(self, user):
|
def get_statuses_for_user(self, user):
|
||||||
return privacy_filter(
|
return privacy_filter(
|
||||||
user,
|
user,
|
||||||
models.Status.objects.select_subclasses(),
|
models.Status.objects.select_subclasses(),
|
||||||
|
@ -162,17 +134,17 @@ class HomeStream(ActivityStream):
|
||||||
|
|
||||||
|
|
||||||
class LocalStream(ActivityStream):
|
class LocalStream(ActivityStream):
|
||||||
""" users you follow """
|
"""users you follow"""
|
||||||
|
|
||||||
key = "local"
|
key = "local"
|
||||||
|
|
||||||
def stream_users(self, status):
|
def get_audience(self, status):
|
||||||
# this stream wants no part in non-public statuses
|
# this stream wants no part in non-public statuses
|
||||||
if status.privacy != "public" or not status.user.local:
|
if status.privacy != "public" or not status.user.local:
|
||||||
return []
|
return []
|
||||||
return super().stream_users(status)
|
return super().get_audience(status)
|
||||||
|
|
||||||
def stream_statuses(self, user):
|
def get_statuses_for_user(self, user):
|
||||||
# all public statuses by a local user
|
# all public statuses by a local user
|
||||||
return privacy_filter(
|
return privacy_filter(
|
||||||
user,
|
user,
|
||||||
|
@ -182,17 +154,17 @@ class LocalStream(ActivityStream):
|
||||||
|
|
||||||
|
|
||||||
class FederatedStream(ActivityStream):
|
class FederatedStream(ActivityStream):
|
||||||
""" users you follow """
|
"""users you follow"""
|
||||||
|
|
||||||
key = "federated"
|
key = "federated"
|
||||||
|
|
||||||
def stream_users(self, status):
|
def get_audience(self, status):
|
||||||
# this stream wants no part in non-public statuses
|
# this stream wants no part in non-public statuses
|
||||||
if status.privacy != "public":
|
if status.privacy != "public":
|
||||||
return []
|
return []
|
||||||
return super().stream_users(status)
|
return super().get_audience(status)
|
||||||
|
|
||||||
def stream_statuses(self, user):
|
def get_statuses_for_user(self, user):
|
||||||
return privacy_filter(
|
return privacy_filter(
|
||||||
user,
|
user,
|
||||||
models.Status.objects.select_subclasses(),
|
models.Status.objects.select_subclasses(),
|
||||||
|
@ -210,14 +182,14 @@ streams = {
|
||||||
@receiver(signals.post_save)
|
@receiver(signals.post_save)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def add_status_on_create(sender, instance, created, *args, **kwargs):
|
def add_status_on_create(sender, instance, created, *args, **kwargs):
|
||||||
""" add newly created statuses to activity feeds """
|
"""add newly created statuses to activity feeds"""
|
||||||
# we're only interested in new statuses
|
# we're only interested in new statuses
|
||||||
if not issubclass(sender, models.Status):
|
if not issubclass(sender, models.Status):
|
||||||
return
|
return
|
||||||
|
|
||||||
if instance.deleted:
|
if instance.deleted:
|
||||||
for stream in streams.values():
|
for stream in streams.values():
|
||||||
stream.remove_status(instance)
|
stream.remove_object_from_related_stores(instance)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not created:
|
if not created:
|
||||||
|
@ -231,16 +203,16 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
||||||
@receiver(signals.post_delete, sender=models.Boost)
|
@receiver(signals.post_delete, sender=models.Boost)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def remove_boost_on_delete(sender, instance, *args, **kwargs):
|
def remove_boost_on_delete(sender, instance, *args, **kwargs):
|
||||||
""" boosts are deleted """
|
"""boosts are deleted"""
|
||||||
# we're only interested in new statuses
|
# we're only interested in new statuses
|
||||||
for stream in streams.values():
|
for stream in streams.values():
|
||||||
stream.remove_status(instance)
|
stream.remove_object_from_related_stores(instance)
|
||||||
|
|
||||||
|
|
||||||
@receiver(signals.post_save, sender=models.UserFollows)
|
@receiver(signals.post_save, sender=models.UserFollows)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
|
def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
|
||||||
""" add a newly followed user's statuses to feeds """
|
"""add a newly followed user's statuses to feeds"""
|
||||||
if not created or not instance.user_subject.local:
|
if not created or not instance.user_subject.local:
|
||||||
return
|
return
|
||||||
HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
|
HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
|
||||||
|
@ -249,7 +221,7 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
|
||||||
@receiver(signals.post_delete, sender=models.UserFollows)
|
@receiver(signals.post_delete, sender=models.UserFollows)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
|
def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
|
||||||
""" remove statuses from a feed on unfollow """
|
"""remove statuses from a feed on unfollow"""
|
||||||
if not instance.user_subject.local:
|
if not instance.user_subject.local:
|
||||||
return
|
return
|
||||||
HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
|
HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
|
||||||
|
@ -258,7 +230,7 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
|
||||||
@receiver(signals.post_save, sender=models.UserBlocks)
|
@receiver(signals.post_save, sender=models.UserBlocks)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
||||||
""" remove statuses from all feeds on block """
|
"""remove statuses from all feeds on block"""
|
||||||
# blocks apply ot all feeds
|
# blocks apply ot all feeds
|
||||||
if instance.user_subject.local:
|
if instance.user_subject.local:
|
||||||
for stream in streams.values():
|
for stream in streams.values():
|
||||||
|
@ -273,7 +245,7 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs):
|
||||||
@receiver(signals.post_delete, sender=models.UserBlocks)
|
@receiver(signals.post_delete, sender=models.UserBlocks)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
|
def add_statuses_on_unblock(sender, instance, *args, **kwargs):
|
||||||
""" remove statuses from all feeds on block """
|
"""remove statuses from all feeds on block"""
|
||||||
public_streams = [LocalStream(), FederatedStream()]
|
public_streams = [LocalStream(), FederatedStream()]
|
||||||
# add statuses back to streams with statuses from anyone
|
# add statuses back to streams with statuses from anyone
|
||||||
if instance.user_subject.local:
|
if instance.user_subject.local:
|
||||||
|
@ -289,9 +261,9 @@ def add_statuses_on_unblock(sender, instance, *args, **kwargs):
|
||||||
@receiver(signals.post_save, sender=models.User)
|
@receiver(signals.post_save, sender=models.User)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def populate_streams_on_account_create(sender, instance, created, *args, **kwargs):
|
def populate_streams_on_account_create(sender, instance, created, *args, **kwargs):
|
||||||
""" build a user's feeds when they join """
|
"""build a user's feeds when they join"""
|
||||||
if not created or not instance.local:
|
if not created or not instance.local:
|
||||||
return
|
return
|
||||||
|
|
||||||
for stream in streams.values():
|
for stream in streams.values():
|
||||||
stream.populate_stream(instance)
|
stream.populate_streams(instance)
|
||||||
|
|
|
@ -16,7 +16,7 @@ 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
|
||||||
|
@ -39,7 +39,7 @@ class AbstractMinimalConnector(ABC):
|
||||||
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
|
||||||
|
@ -55,7 +55,7 @@ class AbstractMinimalConnector(ABC):
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def isbn_search(self, query):
|
def isbn_search(self, query):
|
||||||
""" isbn search """
|
"""isbn search"""
|
||||||
params = {}
|
params = {}
|
||||||
data = self.get_search_data(
|
data = self.get_search_data(
|
||||||
"%s%s" % (self.isbn_search_url, query),
|
"%s%s" % (self.isbn_search_url, query),
|
||||||
|
@ -74,27 +74,27 @@ class AbstractMinimalConnector(ABC):
|
||||||
|
|
||||||
@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
|
@abstractmethod
|
||||||
def parse_isbn_search_data(self, data):
|
def parse_isbn_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_isbn_search_result(self, search_result):
|
def format_isbn_search_result(self, search_result):
|
||||||
""" create a SearchResult obj from json """
|
"""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)
|
||||||
|
@ -103,14 +103,14 @@ class AbstractConnector(AbstractMinimalConnector):
|
||||||
self.book_mappings = []
|
self.book_mappings = []
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
""" check if you're allowed to use this connector """
|
"""check if you're allowed to use this connector"""
|
||||||
if self.max_query_count is not None:
|
if self.max_query_count is not None:
|
||||||
if self.connector.query_count >= self.max_query_count:
|
if self.connector.query_count >= self.max_query_count:
|
||||||
return False
|
return False
|
||||||
return True
|
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(
|
existing = models.Edition.find_existing_by_remote_id(
|
||||||
remote_id
|
remote_id
|
||||||
|
@ -159,7 +159,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
||||||
return get_data(remote_id)
|
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)
|
||||||
|
@ -179,7 +179,7 @@ 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
|
||||||
|
@ -187,29 +187,33 @@ class AbstractConnector(AbstractMinimalConnector):
|
||||||
data = self.get_book_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:
|
||||||
|
activity = activitypub.Author(**mapped_data)
|
||||||
|
except activitypub.ActivitySerializerError:
|
||||||
|
return None
|
||||||
|
|
||||||
# this will dedupe
|
# this will dedupe
|
||||||
return activity.to_model(model=models.Author)
|
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):
|
||||||
|
@ -222,7 +226,13 @@ def dict_from_mappings(data, mappings):
|
||||||
|
|
||||||
|
|
||||||
def get_data(url, params=None):
|
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,
|
||||||
|
@ -248,7 +258,7 @@ def get_data(url, params=None):
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
@ -266,7 +276,7 @@ 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
|
||||||
|
@ -283,14 +293,14 @@ class SearchResult:
|
||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
|
@ -300,7 +310,7 @@ 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
|
||||||
|
|
|
@ -4,7 +4,7 @@ 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(remote_id, model=models.Edition)
|
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
""" interface with whatever connectors the app has """
|
""" interface with whatever connectors the app has """
|
||||||
import importlib
|
import importlib
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
@ -11,13 +12,15 @@ 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):
|
||||||
""" find books based on arbitary keywords """
|
"""find books based on arbitary keywords"""
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
results = []
|
results = []
|
||||||
|
@ -37,14 +40,17 @@ def search(query, min_confidence=0.1):
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
result_set = connector.isbn_search(isbn)
|
result_set = connector.isbn_search(isbn)
|
||||||
except (HTTPError, ConnectorException):
|
except Exception as e: # pylint: disable=broad-except
|
||||||
pass
|
logger.exception(e)
|
||||||
|
continue
|
||||||
|
|
||||||
# if no isbn search or results, we fallback to generic search
|
# if no isbn search or results, we fallback to generic search
|
||||||
if result_set in (None, []):
|
if result_set in (None, []):
|
||||||
try:
|
try:
|
||||||
result_set = connector.search(query, min_confidence=min_confidence)
|
result_set = connector.search(query, min_confidence=min_confidence)
|
||||||
except (HTTPError, ConnectorException):
|
except Exception as e: # pylint: disable=broad-except
|
||||||
|
# we don't want *any* error to crash the whole search page
|
||||||
|
logger.exception(e)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# if the search results look the same, ignore them
|
# if the search results look the same, ignore them
|
||||||
|
@ -61,20 +67,22 @@ def search(query, min_confidence=0.1):
|
||||||
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):
|
def isbn_local_search(query, raw=False):
|
||||||
""" 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.isbn_search(query, raw=raw)
|
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():
|
for connector in get_connectors():
|
||||||
result = connector.search(query, min_confidence=min_confidence)
|
result = connector.search(query, min_confidence=min_confidence)
|
||||||
if result:
|
if result:
|
||||||
|
@ -83,13 +91,13 @@ def first_search_result(query, min_confidence=0.1):
|
||||||
|
|
||||||
|
|
||||||
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.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 object'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:
|
||||||
|
@ -113,7 +121,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)
|
||||||
|
@ -121,7 +129,7 @@ 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
|
||||||
)
|
)
|
||||||
|
@ -131,6 +139,6 @@ def load_connector(connector_info):
|
||||||
@receiver(signals.post_save, sender="bookwyrm.FederatedServer")
|
@receiver(signals.post_save, sender="bookwyrm.FederatedServer")
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def create_connector(sender, instance, created, *args, **kwargs):
|
def create_connector(sender, instance, created, *args, **kwargs):
|
||||||
""" create a connector to an external bookwyrm server """
|
"""create a connector to an external bookwyrm server"""
|
||||||
if instance.application_type == "bookwyrm":
|
if instance.application_type == "bookwyrm":
|
||||||
get_or_create_connector("https://{:s}".format(instance.server_name))
|
get_or_create_connector("https://{:s}".format(instance.server_name))
|
||||||
|
|
|
@ -9,7 +9,7 @@ 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)
|
||||||
|
@ -59,7 +59,7 @@ class Connector(AbstractConnector):
|
||||||
]
|
]
|
||||||
|
|
||||||
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:
|
||||||
|
@ -87,16 +87,19 @@ class Connector(AbstractConnector):
|
||||||
return get_data(url)
|
return get_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, size="L"):
|
||||||
""" ask openlibrary for the cover """
|
"""ask openlibrary for the cover"""
|
||||||
if not cover_blob:
|
if not cover_blob:
|
||||||
return None
|
return None
|
||||||
cover_id = cover_blob[0]
|
cover_id = cover_blob[0]
|
||||||
|
@ -138,7 +141,7 @@ class Connector(AbstractConnector):
|
||||||
)
|
)
|
||||||
|
|
||||||
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 get_data(url)
|
||||||
|
|
||||||
|
@ -163,7 +166,7 @@ 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"):
|
||||||
return False
|
return False
|
||||||
|
@ -182,19 +185,19 @@ def ignore_edition(edition_data):
|
||||||
|
|
||||||
|
|
||||||
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(languages.get(lang.get("key", ""), None))
|
langs.append(languages.get(lang.get("key", ""), None))
|
||||||
|
@ -202,7 +205,7 @@ def get_languages(language_blob):
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
|
@ -10,18 +10,19 @@ 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:
|
||||||
|
@ -35,7 +36,7 @@ class Connector(AbstractConnector):
|
||||||
return search_results
|
return search_results
|
||||||
|
|
||||||
def isbn_search(self, query, raw=False):
|
def isbn_search(self, query, raw=False):
|
||||||
""" search your local database """
|
"""search your local database"""
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@ -87,26 +88,26 @@ class Connector(AbstractConnector):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def parse_isbn_search_data(self, data):
|
def parse_isbn_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 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 = [
|
or_filters = [
|
||||||
{f.name: query}
|
{f.name: query}
|
||||||
for f in models.Edition._meta.get_fields()
|
for f in models.Edition._meta.get_fields()
|
||||||
if hasattr(f, "deduplication_field") and f.deduplication_field
|
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.
|
||||||
|
@ -114,8 +115,8 @@ def search_identifiers(query):
|
||||||
return results.filter(parent_work__default_edition__id=F("id")) or results
|
return results.filter(parent_work__default_edition__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 = (
|
vector = (
|
||||||
SearchVector("title", weight="A")
|
SearchVector("title", weight="A")
|
||||||
+ SearchVector("subtitle", weight="B")
|
+ SearchVector("subtitle", weight="B")
|
||||||
|
@ -126,7 +127,7 @@ def search_title_author(query, min_confidence):
|
||||||
results = (
|
results = (
|
||||||
models.Edition.objects.annotate(search=vector)
|
models.Edition.objects.annotate(search=vector)
|
||||||
.annotate(rank=SearchRank(vector, query))
|
.annotate(rank=SearchRank(vector, query))
|
||||||
.filter(rank__gt=min_confidence)
|
.filter(*filters, rank__gt=min_confidence)
|
||||||
.order_by("-rank")
|
.order_by("-rank")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -3,5 +3,5 @@ from bookwyrm import models
|
||||||
|
|
||||||
|
|
||||||
def site_settings(request): # pylint: disable=unused-argument
|
def site_settings(request): # pylint: disable=unused-argument
|
||||||
""" include the custom info about the site """
|
"""include the custom info about the site"""
|
||||||
return {"site": models.SiteSettings.objects.get()}
|
return {"site": models.SiteSettings.objects.get()}
|
||||||
|
|
|
@ -8,7 +8,7 @@ from bookwyrm.settings import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
def email_data():
|
def email_data():
|
||||||
""" fields every email needs """
|
"""fields every email needs"""
|
||||||
site = models.SiteSettings.objects.get()
|
site = models.SiteSettings.objects.get()
|
||||||
if site.logo_small:
|
if site.logo_small:
|
||||||
logo_path = "/images/{}".format(site.logo_small.url)
|
logo_path = "/images/{}".format(site.logo_small.url)
|
||||||
|
@ -24,14 +24,14 @@ def email_data():
|
||||||
|
|
||||||
|
|
||||||
def invite_email(invite_request):
|
def invite_email(invite_request):
|
||||||
""" send out an invite code """
|
"""send out an invite code"""
|
||||||
data = email_data()
|
data = email_data()
|
||||||
data["invite_link"] = invite_request.invite.link
|
data["invite_link"] = invite_request.invite.link
|
||||||
send_email.delay(invite_request.email, *format_email("invite", data))
|
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"""
|
||||||
data = email_data()
|
data = email_data()
|
||||||
data["reset_link"] = reset_code.link
|
data["reset_link"] = reset_code.link
|
||||||
data["user"] = reset_code.user.display_name
|
data["user"] = reset_code.user.display_name
|
||||||
|
@ -39,7 +39,7 @@ def password_reset_email(reset_code):
|
||||||
|
|
||||||
|
|
||||||
def format_email(email_name, data):
|
def format_email(email_name, data):
|
||||||
""" render the email templates """
|
"""render the email templates"""
|
||||||
subject = (
|
subject = (
|
||||||
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
|
get_template("email/{}/subject.html".format(email_name)).render(data).strip()
|
||||||
)
|
)
|
||||||
|
@ -58,7 +58,7 @@ def format_email(email_name, data):
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def send_email(recipient, subject, html_content, text_content):
|
def send_email(recipient, subject, html_content, text_content):
|
||||||
""" use a task to send the email """
|
"""use a task to send the email"""
|
||||||
email = EmailMultiAlternatives(
|
email = EmailMultiAlternatives(
|
||||||
subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient]
|
subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient]
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,7 +3,7 @@ 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 django.utils.translation import gettext_lazy as _
|
||||||
|
@ -12,7 +12,7 @@ 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: "")
|
||||||
|
@ -150,12 +150,10 @@ class LimitedEditUserForm(CustomForm):
|
||||||
help_texts = {f: None for f in fields}
|
help_texts = {f: None for f in fields}
|
||||||
|
|
||||||
|
|
||||||
class TagForm(CustomForm):
|
class UserGroupForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Tag
|
model = models.User
|
||||||
fields = ["name"]
|
fields = ["groups"]
|
||||||
help_texts = {f: None for f in fields}
|
|
||||||
labels = {"name": "Add a tag"}
|
|
||||||
|
|
||||||
|
|
||||||
class CoverForm(CustomForm):
|
class CoverForm(CustomForm):
|
||||||
|
@ -200,7 +198,7 @@ class ImportForm(forms.Form):
|
||||||
|
|
||||||
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":
|
||||||
|
@ -219,7 +217,7 @@ class ExpiryWidget(widgets.Select):
|
||||||
|
|
||||||
class InviteRequestForm(CustomForm):
|
class InviteRequestForm(CustomForm):
|
||||||
def clean(self):
|
def clean(self):
|
||||||
""" make sure the email isn't in use by a registered user """
|
"""make sure the email isn't in use by a registered user"""
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
email = cleaned_data.get("email")
|
email = cleaned_data.get("email")
|
||||||
if email and models.User.objects.filter(email=email).exists():
|
if email and models.User.objects.filter(email=email).exists():
|
||||||
|
@ -281,3 +279,26 @@ class ReportForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Report
|
model = models.Report
|
||||||
fields = ["user", "reporter", "statuses", "note"]
|
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")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -9,7 +9,7 @@ class GoodreadsImporter(Importer):
|
||||||
service = "GoodReads"
|
service = "GoodReads"
|
||||||
|
|
||||||
def parse_fields(self, entry):
|
def parse_fields(self, entry):
|
||||||
""" handle the specific fields in goodreads csvs """
|
"""handle the specific fields in goodreads csvs"""
|
||||||
entry.update({"import_source": self.service})
|
entry.update({"import_source": self.service})
|
||||||
# add missing 'Date Started' field
|
# add missing 'Date Started' field
|
||||||
entry.update({"Date Started": None})
|
entry.update({"Date Started": None})
|
||||||
|
|
|
@ -10,7 +10,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Importer:
|
class Importer:
|
||||||
""" Generic class for csv data import from an outside service """
|
"""Generic class for csv data import from an outside service"""
|
||||||
|
|
||||||
service = "Unknown"
|
service = "Unknown"
|
||||||
delimiter = ","
|
delimiter = ","
|
||||||
|
@ -18,7 +18,7 @@ class Importer:
|
||||||
mandatory_fields = ["Title", "Author"]
|
mandatory_fields = ["Title", "Author"]
|
||||||
|
|
||||||
def create_job(self, user, csv_file, include_reviews, privacy):
|
def create_job(self, user, csv_file, include_reviews, privacy):
|
||||||
""" check over a csv and creates a database entry for the job"""
|
"""check over a csv and creates a database entry for the job"""
|
||||||
job = ImportJob.objects.create(
|
job = ImportJob.objects.create(
|
||||||
user=user, include_reviews=include_reviews, privacy=privacy
|
user=user, include_reviews=include_reviews, privacy=privacy
|
||||||
)
|
)
|
||||||
|
@ -32,16 +32,16 @@ class Importer:
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def save_item(self, job, index, data): # pylint: disable=no-self-use
|
def save_item(self, job, index, data): # pylint: disable=no-self-use
|
||||||
""" creates and saves an import item """
|
"""creates and saves an import item"""
|
||||||
ImportItem(job=job, index=index, data=data).save()
|
ImportItem(job=job, index=index, data=data).save()
|
||||||
|
|
||||||
def parse_fields(self, entry):
|
def parse_fields(self, entry):
|
||||||
""" updates csv data with additional info """
|
"""updates csv data with additional info"""
|
||||||
entry.update({"import_source": self.service})
|
entry.update({"import_source": self.service})
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
def create_retry_job(self, user, original_job, items):
|
def create_retry_job(self, user, original_job, items):
|
||||||
""" retry items that didn't import """
|
"""retry items that didn't import"""
|
||||||
job = ImportJob.objects.create(
|
job = ImportJob.objects.create(
|
||||||
user=user,
|
user=user,
|
||||||
include_reviews=original_job.include_reviews,
|
include_reviews=original_job.include_reviews,
|
||||||
|
@ -53,7 +53,7 @@ class Importer:
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def start_import(self, job):
|
def start_import(self, job):
|
||||||
""" initalizes a csv import job """
|
"""initalizes a csv import job"""
|
||||||
result = import_data.delay(self.service, job.id)
|
result = import_data.delay(self.service, job.id)
|
||||||
job.task_id = result.id
|
job.task_id = result.id
|
||||||
job.save()
|
job.save()
|
||||||
|
@ -61,7 +61,7 @@ class Importer:
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def import_data(source, job_id):
|
def import_data(source, job_id):
|
||||||
""" does the actual lookup work in a celery task """
|
"""does the actual lookup work in a celery task"""
|
||||||
job = ImportJob.objects.get(id=job_id)
|
job = ImportJob.objects.get(id=job_id)
|
||||||
try:
|
try:
|
||||||
for item in job.items.all():
|
for item in job.items.all():
|
||||||
|
@ -89,7 +89,7 @@ def import_data(source, job_id):
|
||||||
|
|
||||||
|
|
||||||
def handle_imported_book(source, user, item, include_reviews, privacy):
|
def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||||
""" process a csv and then post about it """
|
"""process a csv and then post about it"""
|
||||||
if isinstance(item.book, models.Work):
|
if isinstance(item.book, models.Work):
|
||||||
item.book = item.book.default_edition
|
item.book = item.book.default_edition
|
||||||
if not item.book:
|
if not item.book:
|
||||||
|
@ -116,24 +116,33 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
||||||
read.save()
|
read.save()
|
||||||
|
|
||||||
if include_reviews and (item.rating or item.review):
|
if include_reviews and (item.rating or item.review):
|
||||||
review_title = (
|
|
||||||
"Review of {!r} on {!r}".format(
|
|
||||||
item.book.title,
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
if item.review
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
|
|
||||||
# we don't know the publication date of the review,
|
# we don't know the publication date of the review,
|
||||||
# but "now" is a bad guess
|
# but "now" is a bad guess
|
||||||
published_date_guess = item.date_read or item.date_added
|
published_date_guess = item.date_read or item.date_added
|
||||||
models.Review.objects.create(
|
if item.review:
|
||||||
user=user,
|
review_title = (
|
||||||
book=item.book,
|
"Review of {!r} on {!r}".format(
|
||||||
name=review_title,
|
item.book.title,
|
||||||
content=item.review,
|
source,
|
||||||
rating=item.rating,
|
)
|
||||||
published_date=published_date_guess,
|
if item.review
|
||||||
privacy=privacy,
|
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,
|
||||||
|
)
|
||||||
|
|
|
@ -6,7 +6,7 @@ from . import Importer
|
||||||
|
|
||||||
|
|
||||||
class LibrarythingImporter(Importer):
|
class LibrarythingImporter(Importer):
|
||||||
""" csv downloads from librarything """
|
"""csv downloads from librarything"""
|
||||||
|
|
||||||
service = "LibraryThing"
|
service = "LibraryThing"
|
||||||
delimiter = "\t"
|
delimiter = "\t"
|
||||||
|
@ -15,7 +15,7 @@ class LibrarythingImporter(Importer):
|
||||||
mandatory_fields = ["Title", "Primary Author"]
|
mandatory_fields = ["Title", "Primary Author"]
|
||||||
|
|
||||||
def parse_fields(self, entry):
|
def parse_fields(self, entry):
|
||||||
""" custom parsing for librarything """
|
"""custom parsing for librarything"""
|
||||||
data = {}
|
data = {}
|
||||||
data["import_source"] = self.service
|
data["import_source"] = self.service
|
||||||
data["Book Id"] = entry["Book Id"]
|
data["Book Id"] = entry["Book Id"]
|
||||||
|
|
|
@ -6,7 +6,7 @@ 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 canonical._meta.related_objects
|
(r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
|
||||||
|
@ -24,7 +24,7 @@ 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
|
||||||
|
@ -38,7 +38,7 @@ def copy_data(canonical, obj):
|
||||||
|
|
||||||
|
|
||||||
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 = [
|
dedupe_fields = [
|
||||||
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
|
f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||||
|
@ -68,12 +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)
|
||||||
|
|
|
@ -10,15 +10,15 @@ r = redis.Redis(
|
||||||
|
|
||||||
|
|
||||||
def erase_streams():
|
def erase_streams():
|
||||||
""" throw the whole redis away """
|
"""throw the whole redis away"""
|
||||||
r.flushall()
|
r.flushall()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
""" delete activity streams for all users """
|
"""delete activity streams for all users"""
|
||||||
|
|
||||||
help = "Delete all the user streams"
|
help = "Delete all the user streams"
|
||||||
# pylint: disable=no-self-use,unused-argument
|
# pylint: disable=no-self-use,unused-argument
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
""" flush all, baby """
|
"""flush all, baby"""
|
||||||
erase_streams()
|
erase_streams()
|
||||||
|
|
|
@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
@ -119,6 +119,16 @@ def init_connectors():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
SiteSettings.objects.create()
|
||||||
|
|
||||||
|
@ -130,4 +140,5 @@ class Command(BaseCommand):
|
||||||
init_groups()
|
init_groups()
|
||||||
init_permissions()
|
init_permissions()
|
||||||
init_connectors()
|
init_connectors()
|
||||||
|
init_federated_servers()
|
||||||
init_settings()
|
init_settings()
|
||||||
|
|
|
@ -10,21 +10,21 @@ r = redis.Redis(
|
||||||
|
|
||||||
|
|
||||||
def populate_streams():
|
def populate_streams():
|
||||||
""" build all the streams for all the users """
|
"""build all the streams for all the users"""
|
||||||
users = models.User.objects.filter(
|
users = models.User.objects.filter(
|
||||||
local=True,
|
local=True,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
)
|
)
|
||||||
for user in users:
|
for user in users:
|
||||||
for stream in activitystreams.streams.values():
|
for stream in activitystreams.streams.values():
|
||||||
stream.populate_stream(user)
|
stream.populate_streams(user)
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
""" start all over with user streams """
|
"""start all over with user streams"""
|
||||||
|
|
||||||
help = "Populate streams for all users"
|
help = "Populate streams for all users"
|
||||||
# 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 feed builder """
|
"""run feed builder"""
|
||||||
populate_streams()
|
populate_streams()
|
||||||
|
|
|
@ -5,7 +5,7 @@ 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 = {
|
filters = {
|
||||||
"%s__isnull" % r.name: True for r in models.Edition._meta.related_objects
|
"%s__isnull" % r.name: True for r in models.Edition._meta.related_objects
|
||||||
|
@ -33,10 +33,10 @@ def remove_editions():
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
|
@ -8,7 +8,7 @@ from psycopg2.extras import execute_values
|
||||||
|
|
||||||
|
|
||||||
def convert_review_rating(app_registry, schema_editor):
|
def convert_review_rating(app_registry, schema_editor):
|
||||||
""" take rating type Reviews and convert them to ReviewRatings """
|
"""take rating type Reviews and convert them to ReviewRatings"""
|
||||||
db_alias = schema_editor.connection.alias
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
reviews = (
|
reviews = (
|
||||||
|
@ -29,7 +29,7 @@ VALUES %s""",
|
||||||
|
|
||||||
|
|
||||||
def unconvert_review_rating(app_registry, schema_editor):
|
def unconvert_review_rating(app_registry, schema_editor):
|
||||||
""" undo the conversion from ratings back to reviews"""
|
"""undo the conversion from ratings back to reviews"""
|
||||||
# All we need to do to revert this is drop the table, which Django will do
|
# All we need to do to revert this is drop the table, which Django will do
|
||||||
# on its own, as long as we have a valid reverse function. So, this is a
|
# on its own, as long as we have a valid reverse function. So, this is a
|
||||||
# no-op function so Django will do its thing
|
# no-op function so Django will do its thing
|
||||||
|
|
33
bookwyrm/migrations/0062_auto_20210407_1545.py
Normal file
33
bookwyrm/migrations/0062_auto_20210407_1545.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 3.1.6 on 2021-04-07 15:45
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0061_auto_20210402_1435"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="book",
|
||||||
|
name="series",
|
||||||
|
field=bookwyrm.models.fields.TextField(
|
||||||
|
blank=True, max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="book",
|
||||||
|
name="subtitle",
|
||||||
|
field=bookwyrm.models.fields.TextField(
|
||||||
|
blank=True, max_length=255, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="book",
|
||||||
|
name="title",
|
||||||
|
field=bookwyrm.models.fields.TextField(max_length=255),
|
||||||
|
),
|
||||||
|
]
|
37
bookwyrm/migrations/0063_auto_20210407_1827.py
Normal file
37
bookwyrm/migrations/0063_auto_20210407_1827.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
# Generated by Django 3.1.6 on 2021-04-07 18:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0062_auto_20210407_1545"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="federatedserver",
|
||||||
|
name="notes",
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="federatedserver",
|
||||||
|
name="application_type",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="federatedserver",
|
||||||
|
name="application_version",
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="federatedserver",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[("federated", "Federated"), ("blocked", "Blocked")],
|
||||||
|
default="federated",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
27
bookwyrm/migrations/0063_auto_20210408_1556.py
Normal file
27
bookwyrm/migrations/0063_auto_20210408_1556.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 3.1.6 on 2021-04-08 15:56
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
import django.contrib.postgres.fields.citext
|
||||||
|
import django.contrib.postgres.operations
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0062_auto_20210407_1545"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
django.contrib.postgres.operations.CITextExtension(),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="localname",
|
||||||
|
field=django.contrib.postgres.fields.citext.CICharField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
unique=True,
|
||||||
|
validators=[bookwyrm.models.fields.validate_localname],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
28
bookwyrm/migrations/0064_auto_20210408_2208.py
Normal file
28
bookwyrm/migrations/0064_auto_20210408_2208.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 3.1.6 on 2021-04-08 22:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0063_auto_20210408_1556"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="listitem",
|
||||||
|
name="book_list",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.list"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="shelfbook",
|
||||||
|
name="shelf",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.shelf"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
13
bookwyrm/migrations/0064_merge_20210410_1633.py
Normal file
13
bookwyrm/migrations/0064_merge_20210410_1633.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.1.8 on 2021-04-10 16:33
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0063_auto_20210408_1556"),
|
||||||
|
("bookwyrm", "0063_auto_20210407_1827"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
13
bookwyrm/migrations/0065_merge_20210411_1702.py
Normal file
13
bookwyrm/migrations/0065_merge_20210411_1702.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.1.8 on 2021-04-11 17:02
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0064_auto_20210408_2208"),
|
||||||
|
("bookwyrm", "0064_merge_20210410_1633"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
27
bookwyrm/migrations/0066_user_deactivation_reason.py
Normal file
27
bookwyrm/migrations/0066_user_deactivation_reason.py
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 3.1.8 on 2021-04-12 15:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0065_merge_20210411_1702"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="deactivation_reason",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("self_deletion", "Self Deletion"),
|
||||||
|
("moderator_deletion", "Moderator Deletion"),
|
||||||
|
("domain_block", "Domain Block"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
30
bookwyrm/migrations/0067_denullify_list_item_order.py
Normal file
30
bookwyrm/migrations/0067_denullify_list_item_order.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def forwards_func(apps, schema_editor):
|
||||||
|
# Set all values for ListItem.order
|
||||||
|
BookList = apps.get_model("bookwyrm", "List")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
for book_list in BookList.objects.using(db_alias).all():
|
||||||
|
for i, item in enumerate(book_list.listitem_set.order_by("id"), 1):
|
||||||
|
item.order = i
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_func(apps, schema_editor):
|
||||||
|
# null all values for ListItem.order
|
||||||
|
BookList = apps.get_model("bookwyrm", "List")
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
for book_list in BookList.objects.using(db_alias).all():
|
||||||
|
for item in book_list.listitem_set.order_by("id"):
|
||||||
|
item.order = None
|
||||||
|
item.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0066_user_deactivation_reason"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(forwards_func, reverse_func)]
|
23
bookwyrm/migrations/0068_ordering_for_list_items.py
Normal file
23
bookwyrm/migrations/0068_ordering_for_list_items.py
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
# Generated by Django 3.1.6 on 2021-04-08 16:15
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0067_denullify_list_item_order"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="listitem",
|
||||||
|
name="order",
|
||||||
|
field=bookwyrm.models.fields.IntegerField(),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="listitem",
|
||||||
|
unique_together={("order", "book_list"), ("book", "book_list")},
|
||||||
|
),
|
||||||
|
]
|
34
bookwyrm/migrations/0069_auto_20210422_1604.py
Normal file
34
bookwyrm/migrations/0069_auto_20210422_1604.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# Generated by Django 3.1.8 on 2021-04-22 16:04
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0068_ordering_for_list_items"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="author",
|
||||||
|
name="last_edited_by",
|
||||||
|
field=bookwyrm.models.fields.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="book",
|
||||||
|
name="last_edited_by",
|
||||||
|
field=bookwyrm.models.fields.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
35
bookwyrm/migrations/0070_auto_20210423_0121.py
Normal file
35
bookwyrm/migrations/0070_auto_20210423_0121.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
# Generated by Django 3.1.8 on 2021-04-23 01:21
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0069_auto_20210422_1604"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="usertag",
|
||||||
|
unique_together=None,
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="usertag",
|
||||||
|
name="book",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="usertag",
|
||||||
|
name="tag",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="usertag",
|
||||||
|
name="user",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="Tag",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="UserTag",
|
||||||
|
),
|
||||||
|
]
|
|
@ -17,8 +17,6 @@ from .favorite import Favorite
|
||||||
from .notification import Notification
|
from .notification import Notification
|
||||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||||
|
|
||||||
from .tag import Tag, UserTag
|
|
||||||
|
|
||||||
from .user import User, KeyPair, AnnualGoal
|
from .user import User, KeyPair, AnnualGoal
|
||||||
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||||
from .report import Report, ReportComment
|
from .report import Report, ReportComment
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
""" activitypub model functionality """
|
""" activitypub model functionality """
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
|
from collections import namedtuple
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
import json
|
import json
|
||||||
import operator
|
import operator
|
||||||
|
@ -25,14 +26,23 @@ from bookwyrm.models.fields import ImageField, ManyToManyField
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
# I tried to separate these classes into mutliple files but I kept getting
|
# I tried to separate these classes into mutliple files but I kept getting
|
||||||
# circular import errors so I gave up. I'm sure it could be done though!
|
# circular import errors so I gave up. I'm sure it could be done though!
|
||||||
|
|
||||||
|
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
|
||||||
|
|
||||||
|
|
||||||
|
def set_activity_from_property_field(activity, obj, field):
|
||||||
|
"""assign a model property value to the activity json"""
|
||||||
|
activity[field[1]] = getattr(obj, field[0])
|
||||||
|
|
||||||
|
|
||||||
class ActivitypubMixin:
|
class ActivitypubMixin:
|
||||||
""" add this mixin for models that are AP serializable """
|
"""add this mixin for models that are AP serializable"""
|
||||||
|
|
||||||
activity_serializer = lambda: {}
|
activity_serializer = lambda: {}
|
||||||
reverse_unfurl = False
|
reverse_unfurl = False
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
""" collect some info on model fields """
|
"""collect some info on model fields"""
|
||||||
self.image_fields = []
|
self.image_fields = []
|
||||||
self.many_to_many_fields = []
|
self.many_to_many_fields = []
|
||||||
self.simple_fields = [] # "simple"
|
self.simple_fields = [] # "simple"
|
||||||
|
@ -52,6 +62,12 @@ class ActivitypubMixin:
|
||||||
self.activity_fields = (
|
self.activity_fields = (
|
||||||
self.image_fields + self.many_to_many_fields + self.simple_fields
|
self.image_fields + self.many_to_many_fields + self.simple_fields
|
||||||
)
|
)
|
||||||
|
if hasattr(self, "property_fields"):
|
||||||
|
self.activity_fields += [
|
||||||
|
# pylint: disable=cell-var-from-loop
|
||||||
|
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
|
||||||
|
for f in self.property_fields
|
||||||
|
]
|
||||||
|
|
||||||
# these are separate to avoid infinite recursion issues
|
# these are separate to avoid infinite recursion issues
|
||||||
self.deserialize_reverse_fields = (
|
self.deserialize_reverse_fields = (
|
||||||
|
@ -69,7 +85,7 @@ class ActivitypubMixin:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def find_existing_by_remote_id(cls, remote_id):
|
def find_existing_by_remote_id(cls, remote_id):
|
||||||
""" look up a remote id in the db """
|
"""look up a remote id in the db"""
|
||||||
return cls.find_existing({"id": remote_id})
|
return cls.find_existing({"id": remote_id})
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -110,7 +126,7 @@ class ActivitypubMixin:
|
||||||
return match.first()
|
return match.first()
|
||||||
|
|
||||||
def broadcast(self, activity, sender, software=None):
|
def broadcast(self, activity, sender, software=None):
|
||||||
""" send out an activity """
|
"""send out an activity"""
|
||||||
broadcast_task.delay(
|
broadcast_task.delay(
|
||||||
sender.id,
|
sender.id,
|
||||||
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
json.dumps(activity, cls=activitypub.ActivityEncoder),
|
||||||
|
@ -118,7 +134,7 @@ class ActivitypubMixin:
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_recipients(self, software=None):
|
def get_recipients(self, software=None):
|
||||||
""" figure out which inbox urls to post to """
|
"""figure out which inbox urls to post to"""
|
||||||
# first we have to figure out who should receive this activity
|
# first we have to figure out who should receive this activity
|
||||||
privacy = self.privacy if hasattr(self, "privacy") else "public"
|
privacy = self.privacy if hasattr(self, "privacy") else "public"
|
||||||
# is this activity owned by a user (statuses, lists, shelves), or is it
|
# is this activity owned by a user (statuses, lists, shelves), or is it
|
||||||
|
@ -132,13 +148,17 @@ class ActivitypubMixin:
|
||||||
mentions = self.recipients if hasattr(self, "recipients") else []
|
mentions = self.recipients if hasattr(self, "recipients") else []
|
||||||
|
|
||||||
# we always send activities to explicitly mentioned users' inboxes
|
# we always send activities to explicitly mentioned users' inboxes
|
||||||
recipients = [u.inbox for u in mentions or []]
|
recipients = [u.inbox for u in mentions or [] if not u.local]
|
||||||
|
|
||||||
# unless it's a dm, all the followers should receive the activity
|
# unless it's a dm, all the followers should receive the activity
|
||||||
if privacy != "direct":
|
if privacy != "direct":
|
||||||
# we will send this out to a subset of all remote users
|
# we will send this out to a subset of all remote users
|
||||||
queryset = user_model.objects.filter(
|
queryset = (
|
||||||
local=False,
|
user_model.viewer_aware_objects(user)
|
||||||
|
.filter(
|
||||||
|
local=False,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
)
|
)
|
||||||
# filter users first by whether they're using the desired software
|
# filter users first by whether they're using the desired software
|
||||||
# this lets us send book updates only to other bw servers
|
# this lets us send book updates only to other bw servers
|
||||||
|
@ -159,32 +179,34 @@ class ActivitypubMixin:
|
||||||
"inbox", flat=True
|
"inbox", flat=True
|
||||||
)
|
)
|
||||||
recipients += list(shared_inboxes) + list(inboxes)
|
recipients += list(shared_inboxes) + list(inboxes)
|
||||||
return recipients
|
return list(set(recipients))
|
||||||
|
|
||||||
def to_activity_dataclass(self):
|
def to_activity_dataclass(self):
|
||||||
""" convert from a model to an activity """
|
"""convert from a model to an activity"""
|
||||||
activity = generate_activity(self)
|
activity = generate_activity(self)
|
||||||
return self.activity_serializer(**activity)
|
return self.activity_serializer(**activity)
|
||||||
|
|
||||||
def to_activity(self, **kwargs): # pylint: disable=unused-argument
|
def to_activity(self, **kwargs): # pylint: disable=unused-argument
|
||||||
""" convert from a model to a json activity """
|
"""convert from a model to a json activity"""
|
||||||
return self.to_activity_dataclass().serialize()
|
return self.to_activity_dataclass().serialize()
|
||||||
|
|
||||||
|
|
||||||
class ObjectMixin(ActivitypubMixin):
|
class ObjectMixin(ActivitypubMixin):
|
||||||
""" add this mixin for object models that are AP serializable """
|
"""add this mixin for object models that are AP serializable"""
|
||||||
|
|
||||||
def save(self, *args, created=None, **kwargs):
|
def save(self, *args, created=None, **kwargs):
|
||||||
""" broadcast created/updated/deleted objects as appropriate """
|
"""broadcast created/updated/deleted objects as appropriate"""
|
||||||
broadcast = kwargs.get("broadcast", True)
|
broadcast = kwargs.get("broadcast", True)
|
||||||
# this bonus kwarg woul cause an error in the base save method
|
# this bonus kwarg would cause an error in the base save method
|
||||||
if "broadcast" in kwargs:
|
if "broadcast" in kwargs:
|
||||||
del kwargs["broadcast"]
|
del kwargs["broadcast"]
|
||||||
|
|
||||||
created = created or not bool(self.id)
|
created = created or not bool(self.id)
|
||||||
# first off, we want to save normally no matter what
|
# first off, we want to save normally no matter what
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if not broadcast:
|
if not broadcast or (
|
||||||
|
hasattr(self, "status_type") and self.status_type == "Announce"
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# this will work for objects owned by a user (lists, shelves)
|
# this will work for objects owned by a user (lists, shelves)
|
||||||
|
@ -232,7 +254,7 @@ class ObjectMixin(ActivitypubMixin):
|
||||||
self.broadcast(activity, user)
|
self.broadcast(activity, user)
|
||||||
|
|
||||||
def to_create_activity(self, user, **kwargs):
|
def to_create_activity(self, user, **kwargs):
|
||||||
""" returns the object wrapped in a Create activity """
|
"""returns the object wrapped in a Create activity"""
|
||||||
activity_object = self.to_activity_dataclass(**kwargs)
|
activity_object = self.to_activity_dataclass(**kwargs)
|
||||||
|
|
||||||
signature = None
|
signature = None
|
||||||
|
@ -258,7 +280,7 @@ class ObjectMixin(ActivitypubMixin):
|
||||||
).serialize()
|
).serialize()
|
||||||
|
|
||||||
def to_delete_activity(self, user):
|
def to_delete_activity(self, user):
|
||||||
""" notice of deletion """
|
"""notice of deletion"""
|
||||||
return activitypub.Delete(
|
return activitypub.Delete(
|
||||||
id=self.remote_id + "/activity",
|
id=self.remote_id + "/activity",
|
||||||
actor=user.remote_id,
|
actor=user.remote_id,
|
||||||
|
@ -268,7 +290,7 @@ class ObjectMixin(ActivitypubMixin):
|
||||||
).serialize()
|
).serialize()
|
||||||
|
|
||||||
def to_update_activity(self, user):
|
def to_update_activity(self, user):
|
||||||
""" wrapper for Updates to an activity """
|
"""wrapper for Updates to an activity"""
|
||||||
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
|
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
|
||||||
return activitypub.Update(
|
return activitypub.Update(
|
||||||
id=activity_id,
|
id=activity_id,
|
||||||
|
@ -284,13 +306,13 @@ class OrderedCollectionPageMixin(ObjectMixin):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def collection_remote_id(self):
|
def collection_remote_id(self):
|
||||||
""" this can be overriden if there's a special remote id, ie outbox """
|
"""this can be overriden if there's a special remote id, ie outbox"""
|
||||||
return self.remote_id
|
return self.remote_id
|
||||||
|
|
||||||
def to_ordered_collection(
|
def to_ordered_collection(
|
||||||
self, queryset, remote_id=None, page=False, collection_only=False, **kwargs
|
self, queryset, remote_id=None, page=False, collection_only=False, **kwargs
|
||||||
):
|
):
|
||||||
""" an ordered collection of whatevers """
|
"""an ordered collection of whatevers"""
|
||||||
if not queryset.ordered:
|
if not queryset.ordered:
|
||||||
raise RuntimeError("queryset must be ordered")
|
raise RuntimeError("queryset must be ordered")
|
||||||
|
|
||||||
|
@ -319,11 +341,11 @@ class OrderedCollectionPageMixin(ObjectMixin):
|
||||||
|
|
||||||
|
|
||||||
class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||||
""" extends activitypub models to work as ordered collections """
|
"""extends activitypub models to work as ordered collections"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def collection_queryset(self):
|
def collection_queryset(self):
|
||||||
""" usually an ordered collection model aggregates a different model """
|
"""usually an ordered collection model aggregates a different model"""
|
||||||
raise NotImplementedError("Model must define collection_queryset")
|
raise NotImplementedError("Model must define collection_queryset")
|
||||||
|
|
||||||
activity_serializer = activitypub.OrderedCollection
|
activity_serializer = activitypub.OrderedCollection
|
||||||
|
@ -332,81 +354,98 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||||
return self.to_ordered_collection(self.collection_queryset, **kwargs)
|
return self.to_ordered_collection(self.collection_queryset, **kwargs)
|
||||||
|
|
||||||
def to_activity(self, **kwargs):
|
def to_activity(self, **kwargs):
|
||||||
""" an ordered collection of the specified model queryset """
|
"""an ordered collection of the specified model queryset"""
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
self.collection_queryset, **kwargs
|
self.collection_queryset, **kwargs
|
||||||
).serialize()
|
).serialize()
|
||||||
|
|
||||||
|
|
||||||
class CollectionItemMixin(ActivitypubMixin):
|
class CollectionItemMixin(ActivitypubMixin):
|
||||||
""" for items that are part of an (Ordered)Collection """
|
"""for items that are part of an (Ordered)Collection"""
|
||||||
|
|
||||||
activity_serializer = activitypub.Add
|
activity_serializer = activitypub.CollectionItem
|
||||||
object_field = collection_field = None
|
|
||||||
|
def broadcast(self, activity, sender, software="bookwyrm"):
|
||||||
|
"""only send book collection updates to other bookwyrm instances"""
|
||||||
|
super().broadcast(activity, sender, software=software)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def privacy(self):
|
||||||
|
"""inherit the privacy of the list, or direct if pending"""
|
||||||
|
collection_field = getattr(self, self.collection_field)
|
||||||
|
if self.approved:
|
||||||
|
return collection_field.privacy
|
||||||
|
return "direct"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def recipients(self):
|
||||||
|
"""the owner of the list is a direct recipient"""
|
||||||
|
collection_field = getattr(self, self.collection_field)
|
||||||
|
if collection_field.user.local:
|
||||||
|
# don't broadcast to yourself
|
||||||
|
return []
|
||||||
|
return [collection_field.user]
|
||||||
|
|
||||||
def save(self, *args, broadcast=True, **kwargs):
|
def save(self, *args, broadcast=True, **kwargs):
|
||||||
""" broadcast updated """
|
"""broadcast updated"""
|
||||||
created = not bool(self.id)
|
|
||||||
# first off, we want to save normally no matter what
|
# first off, we want to save normally no matter what
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# these shouldn't be edited, only created and deleted
|
# list items can be updateda, normally you would only broadcast on created
|
||||||
if not broadcast or not created or not self.user.local:
|
if not broadcast or not self.user.local:
|
||||||
return
|
return
|
||||||
|
|
||||||
# adding an obj to the collection
|
# adding an obj to the collection
|
||||||
activity = self.to_add_activity()
|
activity = self.to_add_activity(self.user)
|
||||||
self.broadcast(activity, self.user)
|
self.broadcast(activity, self.user)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, broadcast=True, **kwargs):
|
||||||
""" broadcast a remove activity """
|
"""broadcast a remove activity"""
|
||||||
activity = self.to_remove_activity()
|
activity = self.to_remove_activity(self.user)
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
if self.user.local:
|
if self.user.local and broadcast:
|
||||||
self.broadcast(activity, self.user)
|
self.broadcast(activity, self.user)
|
||||||
|
|
||||||
def to_add_activity(self):
|
def to_add_activity(self, user):
|
||||||
""" AP for shelving a book"""
|
"""AP for shelving a book"""
|
||||||
object_field = getattr(self, self.object_field)
|
|
||||||
collection_field = getattr(self, self.collection_field)
|
collection_field = getattr(self, self.collection_field)
|
||||||
return activitypub.Add(
|
return activitypub.Add(
|
||||||
id=self.remote_id,
|
id="{:s}#add".format(collection_field.remote_id),
|
||||||
actor=self.user.remote_id,
|
actor=user.remote_id,
|
||||||
object=object_field,
|
object=self.to_activity_dataclass(),
|
||||||
target=collection_field.remote_id,
|
target=collection_field.remote_id,
|
||||||
).serialize()
|
).serialize()
|
||||||
|
|
||||||
def to_remove_activity(self):
|
def to_remove_activity(self, user):
|
||||||
""" AP for un-shelving a book"""
|
"""AP for un-shelving a book"""
|
||||||
object_field = getattr(self, self.object_field)
|
|
||||||
collection_field = getattr(self, self.collection_field)
|
collection_field = getattr(self, self.collection_field)
|
||||||
return activitypub.Remove(
|
return activitypub.Remove(
|
||||||
id=self.remote_id,
|
id="{:s}#remove".format(collection_field.remote_id),
|
||||||
actor=self.user.remote_id,
|
actor=user.remote_id,
|
||||||
object=object_field,
|
object=self.to_activity_dataclass(),
|
||||||
target=collection_field.remote_id,
|
target=collection_field.remote_id,
|
||||||
).serialize()
|
).serialize()
|
||||||
|
|
||||||
|
|
||||||
class ActivityMixin(ActivitypubMixin):
|
class ActivityMixin(ActivitypubMixin):
|
||||||
""" add this mixin for models that are AP serializable """
|
"""add this mixin for models that are AP serializable"""
|
||||||
|
|
||||||
def save(self, *args, broadcast=True, **kwargs):
|
def save(self, *args, broadcast=True, **kwargs):
|
||||||
""" broadcast activity """
|
"""broadcast activity"""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
user = self.user if hasattr(self, "user") else self.user_subject
|
user = self.user if hasattr(self, "user") else self.user_subject
|
||||||
if broadcast and user.local:
|
if broadcast and user.local:
|
||||||
self.broadcast(self.to_activity(), user)
|
self.broadcast(self.to_activity(), user)
|
||||||
|
|
||||||
def delete(self, *args, broadcast=True, **kwargs):
|
def delete(self, *args, broadcast=True, **kwargs):
|
||||||
""" nevermind, undo that activity """
|
"""nevermind, undo that activity"""
|
||||||
user = self.user if hasattr(self, "user") else self.user_subject
|
user = self.user if hasattr(self, "user") else self.user_subject
|
||||||
if broadcast and user.local:
|
if broadcast and user.local:
|
||||||
self.broadcast(self.to_undo_activity(), user)
|
self.broadcast(self.to_undo_activity(), user)
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
def to_undo_activity(self):
|
def to_undo_activity(self):
|
||||||
""" undo an action """
|
"""undo an action"""
|
||||||
user = self.user if hasattr(self, "user") else self.user_subject
|
user = self.user if hasattr(self, "user") else self.user_subject
|
||||||
return activitypub.Undo(
|
return activitypub.Undo(
|
||||||
id="%s#undo" % self.remote_id,
|
id="%s#undo" % self.remote_id,
|
||||||
|
@ -416,7 +455,7 @@ class ActivityMixin(ActivitypubMixin):
|
||||||
|
|
||||||
|
|
||||||
def generate_activity(obj):
|
def generate_activity(obj):
|
||||||
""" go through the fields on an object """
|
"""go through the fields on an object"""
|
||||||
activity = {}
|
activity = {}
|
||||||
for field in obj.activity_fields:
|
for field in obj.activity_fields:
|
||||||
field.set_activity_from_field(activity, obj)
|
field.set_activity_from_field(activity, obj)
|
||||||
|
@ -430,7 +469,7 @@ def generate_activity(obj):
|
||||||
) in obj.serialize_reverse_fields:
|
) in obj.serialize_reverse_fields:
|
||||||
related_field = getattr(obj, model_field_name)
|
related_field = getattr(obj, model_field_name)
|
||||||
activity[activity_field_name] = unfurl_related_field(
|
activity[activity_field_name] = unfurl_related_field(
|
||||||
related_field, sort_field
|
related_field, sort_field=sort_field
|
||||||
)
|
)
|
||||||
|
|
||||||
if not activity.get("id"):
|
if not activity.get("id"):
|
||||||
|
@ -439,8 +478,8 @@ def generate_activity(obj):
|
||||||
|
|
||||||
|
|
||||||
def unfurl_related_field(related_field, sort_field=None):
|
def unfurl_related_field(related_field, sort_field=None):
|
||||||
""" load reverse lookups (like public key owner or Status attachment """
|
"""load reverse lookups (like public key owner or Status attachment"""
|
||||||
if hasattr(related_field, "all"):
|
if sort_field and hasattr(related_field, "all"):
|
||||||
return [
|
return [
|
||||||
unfurl_related_field(i) for i in related_field.order_by(sort_field).all()
|
unfurl_related_field(i) for i in related_field.order_by(sort_field).all()
|
||||||
]
|
]
|
||||||
|
@ -455,7 +494,7 @@ def unfurl_related_field(related_field, sort_field=None):
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def broadcast_task(sender_id, activity, recipients):
|
def broadcast_task(sender_id, activity, recipients):
|
||||||
""" the celery task for broadcast """
|
"""the celery task for broadcast"""
|
||||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||||
sender = user_model.objects.get(id=sender_id)
|
sender = user_model.objects.get(id=sender_id)
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
|
@ -466,7 +505,7 @@ def broadcast_task(sender_id, activity, recipients):
|
||||||
|
|
||||||
|
|
||||||
def sign_and_send(sender, data, destination):
|
def sign_and_send(sender, data, destination):
|
||||||
""" crpyto whatever and http junk """
|
"""crpyto whatever and http junk"""
|
||||||
now = http_date()
|
now = http_date()
|
||||||
|
|
||||||
if not sender.key_pair.private_key:
|
if not sender.key_pair.private_key:
|
||||||
|
@ -495,10 +534,10 @@ def sign_and_send(sender, data, destination):
|
||||||
def to_ordered_collection_page(
|
def to_ordered_collection_page(
|
||||||
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
|
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
|
||||||
):
|
):
|
||||||
""" serialize and pagiante a queryset """
|
"""serialize and pagiante a queryset"""
|
||||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||||
|
|
||||||
activity_page = paginated.page(page)
|
activity_page = paginated.get_page(page)
|
||||||
if id_only:
|
if id_only:
|
||||||
items = [s.remote_id for s in activity_page.object_list]
|
items = [s.remote_id for s in activity_page.object_list]
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -8,7 +8,7 @@ from . import fields
|
||||||
|
|
||||||
|
|
||||||
class Attachment(ActivitypubMixin, BookWyrmModel):
|
class Attachment(ActivitypubMixin, BookWyrmModel):
|
||||||
""" an image (or, in the future, video etc) associated with a status """
|
"""an image (or, in the future, video etc) associated with a status"""
|
||||||
|
|
||||||
status = models.ForeignKey(
|
status = models.ForeignKey(
|
||||||
"Status", on_delete=models.CASCADE, related_name="attachments", null=True
|
"Status", on_delete=models.CASCADE, related_name="attachments", null=True
|
||||||
|
@ -16,13 +16,13 @@ class Attachment(ActivitypubMixin, BookWyrmModel):
|
||||||
reverse_unfurl = True
|
reverse_unfurl = True
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" one day we'll have other types of attachments besides images """
|
"""one day we'll have other types of attachments besides images"""
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
class Image(Attachment):
|
class Image(Attachment):
|
||||||
""" an image attachment """
|
"""an image attachment"""
|
||||||
|
|
||||||
image = fields.ImageField(
|
image = fields.ImageField(
|
||||||
upload_to="status/",
|
upload_to="status/",
|
||||||
|
@ -33,4 +33,4 @@ class Image(Attachment):
|
||||||
)
|
)
|
||||||
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
|
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
|
||||||
|
|
||||||
activity_serializer = activitypub.Image
|
activity_serializer = activitypub.Document
|
||||||
|
|
|
@ -9,7 +9,7 @@ from . import fields
|
||||||
|
|
||||||
|
|
||||||
class Author(BookDataModel):
|
class Author(BookDataModel):
|
||||||
""" basic biographic info """
|
"""basic biographic info"""
|
||||||
|
|
||||||
wikipedia_link = fields.CharField(
|
wikipedia_link = fields.CharField(
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
|
@ -33,7 +33,7 @@ class Author(BookDataModel):
|
||||||
bio = fields.HtmlField(null=True, blank=True)
|
bio = fields.HtmlField(null=True, blank=True)
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
""" editions and works both use "book" instead of model_name """
|
"""editions and works both use "book" instead of model_name"""
|
||||||
return "https://%s/author/%s" % (DOMAIN, self.id)
|
return "https://%s/author/%s" % (DOMAIN, self.id)
|
||||||
|
|
||||||
activity_serializer = activitypub.Author
|
activity_serializer = activitypub.Author
|
||||||
|
|
|
@ -7,14 +7,14 @@ from .fields import RemoteIdField
|
||||||
|
|
||||||
|
|
||||||
class BookWyrmModel(models.Model):
|
class BookWyrmModel(models.Model):
|
||||||
""" shared fields """
|
"""shared fields"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
updated_date = models.DateTimeField(auto_now=True)
|
updated_date = models.DateTimeField(auto_now=True)
|
||||||
remote_id = RemoteIdField(null=True, activitypub_field="id")
|
remote_id = RemoteIdField(null=True, activitypub_field="id")
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
""" generate a url that resolves to the local object """
|
"""generate a url that resolves to the local object"""
|
||||||
base_path = "https://%s" % DOMAIN
|
base_path = "https://%s" % DOMAIN
|
||||||
if hasattr(self, "user"):
|
if hasattr(self, "user"):
|
||||||
base_path = "%s%s" % (base_path, self.user.local_path)
|
base_path = "%s%s" % (base_path, self.user.local_path)
|
||||||
|
@ -22,20 +22,50 @@ class BookWyrmModel(models.Model):
|
||||||
return "%s/%s/%d" % (base_path, model_name, self.id)
|
return "%s/%s/%d" % (base_path, model_name, self.id)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" this is just here to provide default fields for other models """
|
"""this is just here to provide default fields for other models"""
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def local_path(self):
|
def local_path(self):
|
||||||
""" how to link to this object in the local app """
|
"""how to link to this object in the local app"""
|
||||||
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
||||||
|
|
||||||
|
def visible_to_user(self, viewer):
|
||||||
|
"""is a user authorized to view an object?"""
|
||||||
|
# make sure this is an object with privacy owned by a user
|
||||||
|
if not hasattr(self, "user") or not hasattr(self, "privacy"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# viewer can't see it if the object's owner blocked them
|
||||||
|
if viewer in self.user.blocks.all():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# you can see your own posts and any public or unlisted posts
|
||||||
|
if viewer == self.user or self.privacy in ["public", "unlisted"]:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# you can see the followers only posts of people you follow
|
||||||
|
if (
|
||||||
|
self.privacy == "followers"
|
||||||
|
and self.user.followers.filter(id=viewer.id).first()
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
# you can see dms you are tagged in
|
||||||
|
if hasattr(self, "mention_users"):
|
||||||
|
if (
|
||||||
|
self.privacy == "direct"
|
||||||
|
and self.mention_users.filter(id=viewer.id).first()
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_save)
|
@receiver(models.signals.post_save)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def set_remote_id(sender, instance, created, *args, **kwargs):
|
def set_remote_id(sender, instance, created, *args, **kwargs):
|
||||||
""" set the remote_id after save (when the id is available) """
|
"""set the remote_id after save (when the id is available)"""
|
||||||
if not created or not hasattr(instance, "get_remote_id"):
|
if not created or not hasattr(instance, "get_remote_id"):
|
||||||
return
|
return
|
||||||
if not instance.remote_id:
|
if not instance.remote_id:
|
||||||
|
|
|
@ -13,7 +13,7 @@ from . import fields
|
||||||
|
|
||||||
|
|
||||||
class BookDataModel(ObjectMixin, BookWyrmModel):
|
class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||||
""" fields shared between editable book data (books, works, authors) """
|
"""fields shared between editable book data (books, works, authors)"""
|
||||||
|
|
||||||
origin_id = models.CharField(max_length=255, null=True, blank=True)
|
origin_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
openlibrary_key = fields.CharField(
|
openlibrary_key = fields.CharField(
|
||||||
|
@ -32,15 +32,19 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||||
max_length=255, blank=True, null=True, deduplication_field=True
|
max_length=255, blank=True, null=True, deduplication_field=True
|
||||||
)
|
)
|
||||||
|
|
||||||
last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True)
|
last_edited_by = fields.ForeignKey(
|
||||||
|
"User",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
null=True,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" can't initialize this model, that wouldn't make sense """
|
"""can't initialize this model, that wouldn't make sense"""
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" ensure that the remote_id is within this instance """
|
"""ensure that the remote_id is within this instance"""
|
||||||
if self.id:
|
if self.id:
|
||||||
self.remote_id = self.get_remote_id()
|
self.remote_id = self.get_remote_id()
|
||||||
else:
|
else:
|
||||||
|
@ -49,24 +53,24 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
def broadcast(self, activity, sender, software="bookwyrm"):
|
||||||
""" only send book data updates to other bookwyrm instances """
|
"""only send book data updates to other bookwyrm instances"""
|
||||||
super().broadcast(activity, sender, software=software)
|
super().broadcast(activity, sender, software=software)
|
||||||
|
|
||||||
|
|
||||||
class Book(BookDataModel):
|
class Book(BookDataModel):
|
||||||
""" a generic book, which can mean either an edition or a work """
|
"""a generic book, which can mean either an edition or a work"""
|
||||||
|
|
||||||
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
|
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
|
||||||
|
|
||||||
# book/work metadata
|
# book/work metadata
|
||||||
title = fields.CharField(max_length=255)
|
title = fields.TextField(max_length=255)
|
||||||
sort_title = fields.CharField(max_length=255, blank=True, null=True)
|
sort_title = fields.CharField(max_length=255, blank=True, null=True)
|
||||||
subtitle = fields.CharField(max_length=255, blank=True, null=True)
|
subtitle = fields.TextField(max_length=255, blank=True, null=True)
|
||||||
description = fields.HtmlField(blank=True, null=True)
|
description = fields.HtmlField(blank=True, null=True)
|
||||||
languages = fields.ArrayField(
|
languages = fields.ArrayField(
|
||||||
models.CharField(max_length=255), blank=True, default=list
|
models.CharField(max_length=255), blank=True, default=list
|
||||||
)
|
)
|
||||||
series = fields.CharField(max_length=255, blank=True, null=True)
|
series = fields.TextField(max_length=255, blank=True, null=True)
|
||||||
series_number = fields.CharField(max_length=255, blank=True, null=True)
|
series_number = fields.CharField(max_length=255, blank=True, null=True)
|
||||||
subjects = fields.ArrayField(
|
subjects = fields.ArrayField(
|
||||||
models.CharField(max_length=255), blank=True, null=True, default=list
|
models.CharField(max_length=255), blank=True, null=True, default=list
|
||||||
|
@ -85,17 +89,17 @@ class Book(BookDataModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def author_text(self):
|
def author_text(self):
|
||||||
""" format a list of authors """
|
"""format a list of authors"""
|
||||||
return ", ".join(a.name for a in self.authors.all())
|
return ", ".join(a.name for a in self.authors.all())
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def latest_readthrough(self):
|
def latest_readthrough(self):
|
||||||
""" most recent readthrough activity """
|
"""most recent readthrough activity"""
|
||||||
return self.readthrough_set.order_by("-updated_date").first()
|
return self.readthrough_set.order_by("-updated_date").first()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def edition_info(self):
|
def edition_info(self):
|
||||||
""" properties of this edition, as a string """
|
"""properties of this edition, as a string"""
|
||||||
items = [
|
items = [
|
||||||
self.physical_format if hasattr(self, "physical_format") else None,
|
self.physical_format if hasattr(self, "physical_format") else None,
|
||||||
self.languages[0] + " language"
|
self.languages[0] + " language"
|
||||||
|
@ -108,20 +112,20 @@ class Book(BookDataModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alt_text(self):
|
def alt_text(self):
|
||||||
""" image alt test """
|
"""image alt test"""
|
||||||
text = "%s" % self.title
|
text = "%s" % self.title
|
||||||
if self.edition_info:
|
if self.edition_info:
|
||||||
text += " (%s)" % self.edition_info
|
text += " (%s)" % self.edition_info
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" can't be abstract for query reasons, but you shouldn't USE it """
|
"""can't be abstract for query reasons, but you shouldn't USE it"""
|
||||||
if not isinstance(self, Edition) and not isinstance(self, Work):
|
if not isinstance(self, Edition) and not isinstance(self, Work):
|
||||||
raise ValueError("Books should be added as Editions or Works")
|
raise ValueError("Books should be added as Editions or Works")
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
""" editions and works both use "book" instead of model_name """
|
"""editions and works both use "book" instead of model_name"""
|
||||||
return "https://%s/book/%d" % (DOMAIN, self.id)
|
return "https://%s/book/%d" % (DOMAIN, self.id)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
@ -133,7 +137,7 @@ class Book(BookDataModel):
|
||||||
|
|
||||||
|
|
||||||
class Work(OrderedCollectionPageMixin, Book):
|
class Work(OrderedCollectionPageMixin, Book):
|
||||||
""" a work (an abstract concept of a book that manifests in an edition) """
|
"""a work (an abstract concept of a book that manifests in an edition)"""
|
||||||
|
|
||||||
# library of congress catalog control number
|
# library of congress catalog control number
|
||||||
lccn = fields.CharField(
|
lccn = fields.CharField(
|
||||||
|
@ -145,19 +149,19 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" set some fields on the edition object """
|
"""set some fields on the edition object"""
|
||||||
# set rank
|
# set rank
|
||||||
for edition in self.editions.all():
|
for edition in self.editions.all():
|
||||||
edition.save()
|
edition.save()
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
def get_default_edition(self):
|
def get_default_edition(self):
|
||||||
""" in case the default edition is not set """
|
"""in case the default edition is not set"""
|
||||||
return self.default_edition or self.editions.order_by("-edition_rank").first()
|
return self.default_edition or self.editions.order_by("-edition_rank").first()
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def reset_default_edition(self):
|
def reset_default_edition(self):
|
||||||
""" sets a new default edition based on computed rank """
|
"""sets a new default edition based on computed rank"""
|
||||||
self.default_edition = None
|
self.default_edition = None
|
||||||
# editions are re-ranked implicitly
|
# editions are re-ranked implicitly
|
||||||
self.save()
|
self.save()
|
||||||
|
@ -165,11 +169,11 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def to_edition_list(self, **kwargs):
|
def to_edition_list(self, **kwargs):
|
||||||
""" an ordered collection of editions """
|
"""an ordered collection of editions"""
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
self.editions.order_by("-edition_rank").all(),
|
self.editions.order_by("-edition_rank").all(),
|
||||||
remote_id="%s/editions" % self.remote_id,
|
remote_id="%s/editions" % self.remote_id,
|
||||||
**kwargs
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
activity_serializer = activitypub.Work
|
activity_serializer = activitypub.Work
|
||||||
|
@ -178,7 +182,7 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||||
|
|
||||||
|
|
||||||
class Edition(Book):
|
class Edition(Book):
|
||||||
""" an edition of a book """
|
"""an edition of a book"""
|
||||||
|
|
||||||
# these identifiers only apply to editions, not works
|
# these identifiers only apply to editions, not works
|
||||||
isbn_10 = fields.CharField(
|
isbn_10 = fields.CharField(
|
||||||
|
@ -217,7 +221,7 @@ class Edition(Book):
|
||||||
name_field = "title"
|
name_field = "title"
|
||||||
|
|
||||||
def get_rank(self, ignore_default=False):
|
def get_rank(self, ignore_default=False):
|
||||||
""" calculate how complete the data is on this edition """
|
"""calculate how complete the data is on this edition"""
|
||||||
if (
|
if (
|
||||||
not ignore_default
|
not ignore_default
|
||||||
and self.parent_work
|
and self.parent_work
|
||||||
|
@ -237,7 +241,7 @@ class Edition(Book):
|
||||||
return rank
|
return rank
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" set some fields on the edition object """
|
"""set some fields on the edition object"""
|
||||||
# calculate isbn 10/13
|
# calculate isbn 10/13
|
||||||
if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10:
|
if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10:
|
||||||
self.isbn_10 = isbn_13_to_10(self.isbn_13)
|
self.isbn_10 = isbn_13_to_10(self.isbn_13)
|
||||||
|
@ -251,7 +255,7 @@ class Edition(Book):
|
||||||
|
|
||||||
|
|
||||||
def isbn_10_to_13(isbn_10):
|
def isbn_10_to_13(isbn_10):
|
||||||
""" convert an isbn 10 into an isbn 13 """
|
"""convert an isbn 10 into an isbn 13"""
|
||||||
isbn_10 = re.sub(r"[^0-9X]", "", isbn_10)
|
isbn_10 = re.sub(r"[^0-9X]", "", isbn_10)
|
||||||
# drop the last character of the isbn 10 number (the original checkdigit)
|
# drop the last character of the isbn 10 number (the original checkdigit)
|
||||||
converted = isbn_10[:9]
|
converted = isbn_10[:9]
|
||||||
|
@ -273,7 +277,7 @@ def isbn_10_to_13(isbn_10):
|
||||||
|
|
||||||
|
|
||||||
def isbn_13_to_10(isbn_13):
|
def isbn_13_to_10(isbn_13):
|
||||||
""" convert isbn 13 to 10, if possible """
|
"""convert isbn 13 to 10, if possible"""
|
||||||
if isbn_13[:3] != "978":
|
if isbn_13[:3] != "978":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
|
||||||
|
|
||||||
|
|
||||||
class Connector(BookWyrmModel):
|
class Connector(BookWyrmModel):
|
||||||
""" book data source connectors """
|
"""book data source connectors"""
|
||||||
|
|
||||||
identifier = models.CharField(max_length=255, unique=True)
|
identifier = models.CharField(max_length=255, unique=True)
|
||||||
priority = models.IntegerField(default=2)
|
priority = models.IntegerField(default=2)
|
||||||
|
|
|
@ -11,7 +11,7 @@ from .status import Status
|
||||||
|
|
||||||
|
|
||||||
class Favorite(ActivityMixin, BookWyrmModel):
|
class Favorite(ActivityMixin, BookWyrmModel):
|
||||||
""" fav'ing a post """
|
"""fav'ing a post"""
|
||||||
|
|
||||||
user = fields.ForeignKey(
|
user = fields.ForeignKey(
|
||||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||||
|
@ -24,11 +24,11 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ignore_activity(cls, activity):
|
def ignore_activity(cls, activity):
|
||||||
""" don't bother with incoming favs of unknown statuses """
|
"""don't bother with incoming favs of unknown statuses"""
|
||||||
return not Status.objects.filter(remote_id=activity.object).exists()
|
return not Status.objects.filter(remote_id=activity.object).exists()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" update user active time """
|
"""update user active time"""
|
||||||
self.user.last_active_date = timezone.now()
|
self.user.last_active_date = timezone.now()
|
||||||
self.user.save(broadcast=False)
|
self.user.save(broadcast=False)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
@ -45,7 +45,7 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
""" delete and delete notifications """
|
"""delete and delete notifications"""
|
||||||
# check for notification
|
# check for notification
|
||||||
if self.status.user.local:
|
if self.status.user.local:
|
||||||
notification_model = apps.get_model(
|
notification_model = apps.get_model(
|
||||||
|
@ -62,6 +62,6 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" can't fav things twice """
|
"""can't fav things twice"""
|
||||||
|
|
||||||
unique_together = ("user", "status")
|
unique_together = ("user", "status")
|
||||||
|
|
|
@ -1,17 +1,51 @@
|
||||||
""" connections to external ActivityPub servers """
|
""" connections to external ActivityPub servers """
|
||||||
|
from urllib.parse import urlparse
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
|
|
||||||
|
FederationStatus = models.TextChoices(
|
||||||
|
"Status",
|
||||||
|
[
|
||||||
|
"federated",
|
||||||
|
"blocked",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class FederatedServer(BookWyrmModel):
|
class FederatedServer(BookWyrmModel):
|
||||||
""" store which servers we federate with """
|
"""store which servers we federate with"""
|
||||||
|
|
||||||
server_name = models.CharField(max_length=255, unique=True)
|
server_name = models.CharField(max_length=255, unique=True)
|
||||||
# federated, blocked, whatever else
|
status = models.CharField(
|
||||||
status = models.CharField(max_length=255, default="federated")
|
max_length=255, default="federated", choices=FederationStatus.choices
|
||||||
|
)
|
||||||
# is it mastodon, bookwyrm, etc
|
# is it mastodon, bookwyrm, etc
|
||||||
application_type = models.CharField(max_length=255, null=True)
|
application_type = models.CharField(max_length=255, null=True, blank=True)
|
||||||
application_version = models.CharField(max_length=255, null=True)
|
application_version = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
notes = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
def block(self):
|
||||||
|
"""block a server"""
|
||||||
|
self.status = "blocked"
|
||||||
|
self.save()
|
||||||
|
|
||||||
# TODO: blocked servers
|
# deactivate all associated users
|
||||||
|
self.user_set.filter(is_active=True).update(
|
||||||
|
is_active=False, deactivation_reason="domain_block"
|
||||||
|
)
|
||||||
|
|
||||||
|
def unblock(self):
|
||||||
|
"""unblock a server"""
|
||||||
|
self.status = "federated"
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
self.user_set.filter(deactivation_reason="domain_block").update(
|
||||||
|
is_active=True, deactivation_reason=None
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_blocked(cls, url):
|
||||||
|
"""look up if a domain is blocked"""
|
||||||
|
url = urlparse(url)
|
||||||
|
domain = url.netloc
|
||||||
|
return cls.objects.filter(server_name=domain, status="blocked").exists()
|
||||||
|
|
|
@ -18,7 +18,7 @@ from bookwyrm.settings import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
def validate_remote_id(value):
|
def validate_remote_id(value):
|
||||||
""" make sure the remote_id looks like a url """
|
"""make sure the remote_id looks like a url"""
|
||||||
if not value or not re.match(r"^http.?:\/\/[^\s]+$", value):
|
if not value or not re.match(r"^http.?:\/\/[^\s]+$", value):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%(value)s is not a valid remote_id"),
|
_("%(value)s is not a valid remote_id"),
|
||||||
|
@ -27,7 +27,7 @@ def validate_remote_id(value):
|
||||||
|
|
||||||
|
|
||||||
def validate_localname(value):
|
def validate_localname(value):
|
||||||
""" make sure localnames look okay """
|
"""make sure localnames look okay"""
|
||||||
if not re.match(r"^[A-Za-z\-_\.0-9]+$", value):
|
if not re.match(r"^[A-Za-z\-_\.0-9]+$", value):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%(value)s is not a valid username"),
|
_("%(value)s is not a valid username"),
|
||||||
|
@ -36,7 +36,7 @@ def validate_localname(value):
|
||||||
|
|
||||||
|
|
||||||
def validate_username(value):
|
def validate_username(value):
|
||||||
""" make sure usernames look okay """
|
"""make sure usernames look okay"""
|
||||||
if not re.match(r"^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$", value):
|
if not re.match(r"^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$", value):
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
_("%(value)s is not a valid username"),
|
_("%(value)s is not a valid username"),
|
||||||
|
@ -45,7 +45,7 @@ def validate_username(value):
|
||||||
|
|
||||||
|
|
||||||
class ActivitypubFieldMixin:
|
class ActivitypubFieldMixin:
|
||||||
""" make a database field serializable """
|
"""make a database field serializable"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -64,7 +64,7 @@ class ActivitypubFieldMixin:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def set_field_from_activity(self, instance, data):
|
def set_field_from_activity(self, instance, data):
|
||||||
""" helper function for assinging a value to the field """
|
"""helper function for assinging a value to the field"""
|
||||||
try:
|
try:
|
||||||
value = getattr(data, self.get_activitypub_field())
|
value = getattr(data, self.get_activitypub_field())
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
|
@ -78,7 +78,7 @@ class ActivitypubFieldMixin:
|
||||||
setattr(instance, self.name, formatted)
|
setattr(instance, self.name, formatted)
|
||||||
|
|
||||||
def set_activity_from_field(self, activity, instance):
|
def set_activity_from_field(self, activity, instance):
|
||||||
""" update the json object """
|
"""update the json object"""
|
||||||
value = getattr(instance, self.name)
|
value = getattr(instance, self.name)
|
||||||
formatted = self.field_to_activity(value)
|
formatted = self.field_to_activity(value)
|
||||||
if formatted is None:
|
if formatted is None:
|
||||||
|
@ -94,19 +94,19 @@ class ActivitypubFieldMixin:
|
||||||
activity[key] = formatted
|
activity[key] = formatted
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
""" formatter to convert a model value into activitypub """
|
"""formatter to convert a model value into activitypub"""
|
||||||
if hasattr(self, "activitypub_wrapper"):
|
if hasattr(self, "activitypub_wrapper"):
|
||||||
return {self.activitypub_wrapper: value}
|
return {self.activitypub_wrapper: value}
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
""" formatter to convert activitypub into a model value """
|
"""formatter to convert activitypub into a model value"""
|
||||||
if value and hasattr(self, "activitypub_wrapper"):
|
if value and hasattr(self, "activitypub_wrapper"):
|
||||||
value = value.get(self.activitypub_wrapper)
|
value = value.get(self.activitypub_wrapper)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def get_activitypub_field(self):
|
def get_activitypub_field(self):
|
||||||
""" model_field_name to activitypubFieldName """
|
"""model_field_name to activitypubFieldName"""
|
||||||
if self.activitypub_field:
|
if self.activitypub_field:
|
||||||
return self.activitypub_field
|
return self.activitypub_field
|
||||||
name = self.name.split(".")[-1]
|
name = self.name.split(".")[-1]
|
||||||
|
@ -115,7 +115,7 @@ class ActivitypubFieldMixin:
|
||||||
|
|
||||||
|
|
||||||
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
||||||
""" default (de)serialization for foreign key and one to one """
|
"""default (de)serialization for foreign key and one to one"""
|
||||||
|
|
||||||
def __init__(self, *args, load_remote=True, **kwargs):
|
def __init__(self, *args, load_remote=True, **kwargs):
|
||||||
self.load_remote = load_remote
|
self.load_remote = load_remote
|
||||||
|
@ -146,7 +146,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
||||||
|
|
||||||
|
|
||||||
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
||||||
""" a url that serves as a unique identifier """
|
"""a url that serves as a unique identifier"""
|
||||||
|
|
||||||
def __init__(self, *args, max_length=255, validators=None, **kwargs):
|
def __init__(self, *args, max_length=255, validators=None, **kwargs):
|
||||||
validators = validators or [validate_remote_id]
|
validators = validators or [validate_remote_id]
|
||||||
|
@ -156,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
||||||
|
|
||||||
|
|
||||||
class UsernameField(ActivitypubFieldMixin, models.CharField):
|
class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||||
""" activitypub-aware username field """
|
"""activitypub-aware username field"""
|
||||||
|
|
||||||
def __init__(self, activitypub_field="preferredUsername", **kwargs):
|
def __init__(self, activitypub_field="preferredUsername", **kwargs):
|
||||||
self.activitypub_field = activitypub_field
|
self.activitypub_field = activitypub_field
|
||||||
|
@ -172,7 +172,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||||
)
|
)
|
||||||
|
|
||||||
def deconstruct(self):
|
def deconstruct(self):
|
||||||
""" implementation of models.Field deconstruct """
|
"""implementation of models.Field deconstruct"""
|
||||||
name, path, args, kwargs = super().deconstruct()
|
name, path, args, kwargs = super().deconstruct()
|
||||||
del kwargs["verbose_name"]
|
del kwargs["verbose_name"]
|
||||||
del kwargs["max_length"]
|
del kwargs["max_length"]
|
||||||
|
@ -191,7 +191,7 @@ PrivacyLevels = models.TextChoices(
|
||||||
|
|
||||||
|
|
||||||
class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||||
""" this maps to two differente activitypub fields """
|
"""this maps to two differente activitypub fields"""
|
||||||
|
|
||||||
public = "https://www.w3.org/ns/activitystreams#Public"
|
public = "https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
|
||||||
|
@ -236,7 +236,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||||
|
|
||||||
|
|
||||||
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
||||||
""" activitypub-aware foreign key field """
|
"""activitypub-aware foreign key field"""
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
|
@ -245,7 +245,7 @@ class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
||||||
|
|
||||||
|
|
||||||
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
|
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
|
||||||
""" activitypub-aware foreign key field """
|
"""activitypub-aware foreign key field"""
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
|
@ -254,14 +254,14 @@ class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
|
||||||
|
|
||||||
|
|
||||||
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
""" activitypub-aware many to many field """
|
"""activitypub-aware many to many field"""
|
||||||
|
|
||||||
def __init__(self, *args, link_only=False, **kwargs):
|
def __init__(self, *args, link_only=False, **kwargs):
|
||||||
self.link_only = link_only
|
self.link_only = link_only
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def set_field_from_activity(self, instance, data):
|
def set_field_from_activity(self, instance, data):
|
||||||
""" helper function for assinging a value to the field """
|
"""helper function for assinging a value to the field"""
|
||||||
value = getattr(data, self.get_activitypub_field())
|
value = getattr(data, self.get_activitypub_field())
|
||||||
formatted = self.field_from_activity(value)
|
formatted = self.field_from_activity(value)
|
||||||
if formatted is None or formatted is MISSING:
|
if formatted is None or formatted is MISSING:
|
||||||
|
@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
return [i.remote_id for i in value.all()]
|
return [i.remote_id for i in value.all()]
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
items = []
|
|
||||||
if value is None or value is MISSING:
|
if value is None or value is MISSING:
|
||||||
return []
|
return None
|
||||||
|
if not isinstance(value, list):
|
||||||
|
# If this is a link, we currently aren't doing anything with it
|
||||||
|
return None
|
||||||
|
items = []
|
||||||
for remote_id in value:
|
for remote_id in value:
|
||||||
try:
|
try:
|
||||||
validate_remote_id(remote_id)
|
validate_remote_id(remote_id)
|
||||||
|
@ -290,7 +293,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
|
|
||||||
|
|
||||||
class TagField(ManyToManyField):
|
class TagField(ManyToManyField):
|
||||||
""" special case of many to many that uses Tags """
|
"""special case of many to many that uses Tags"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
@ -330,17 +333,17 @@ class TagField(ManyToManyField):
|
||||||
|
|
||||||
|
|
||||||
def image_serializer(value, alt):
|
def image_serializer(value, alt):
|
||||||
""" helper for serializing images """
|
"""helper for serializing images"""
|
||||||
if value and hasattr(value, "url"):
|
if value and hasattr(value, "url"):
|
||||||
url = value.url
|
url = value.url
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
url = "https://%s%s" % (DOMAIN, url)
|
url = "https://%s%s" % (DOMAIN, url)
|
||||||
return activitypub.Image(url=url, name=alt)
|
return activitypub.Document(url=url, name=alt)
|
||||||
|
|
||||||
|
|
||||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
""" activitypub-aware image field """
|
"""activitypub-aware image field"""
|
||||||
|
|
||||||
def __init__(self, *args, alt_field=None, **kwargs):
|
def __init__(self, *args, alt_field=None, **kwargs):
|
||||||
self.alt_field = alt_field
|
self.alt_field = alt_field
|
||||||
|
@ -348,7 +351,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
|
|
||||||
# pylint: disable=arguments-differ
|
# pylint: disable=arguments-differ
|
||||||
def set_field_from_activity(self, instance, data, save=True):
|
def set_field_from_activity(self, instance, data, save=True):
|
||||||
""" helper function for assinging a value to the field """
|
"""helper function for assinging a value to the field"""
|
||||||
value = getattr(data, self.get_activitypub_field())
|
value = getattr(data, self.get_activitypub_field())
|
||||||
formatted = self.field_from_activity(value)
|
formatted = self.field_from_activity(value)
|
||||||
if formatted is None or formatted is MISSING:
|
if formatted is None or formatted is MISSING:
|
||||||
|
@ -394,7 +397,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
|
|
||||||
|
|
||||||
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||||
""" activitypub-aware datetime field """
|
"""activitypub-aware datetime field"""
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
|
@ -413,7 +416,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||||
|
|
||||||
|
|
||||||
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||||
""" a text field for storing html """
|
"""a text field for storing html"""
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
if not value or value == MISSING:
|
if not value or value == MISSING:
|
||||||
|
@ -424,30 +427,30 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||||
|
|
||||||
|
|
||||||
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
||||||
""" activitypub-aware array field """
|
"""activitypub-aware array field"""
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
return [str(i) for i in value]
|
return [str(i) for i in value]
|
||||||
|
|
||||||
|
|
||||||
class CharField(ActivitypubFieldMixin, models.CharField):
|
class CharField(ActivitypubFieldMixin, models.CharField):
|
||||||
""" activitypub-aware char field """
|
"""activitypub-aware char field"""
|
||||||
|
|
||||||
|
|
||||||
class TextField(ActivitypubFieldMixin, models.TextField):
|
class TextField(ActivitypubFieldMixin, models.TextField):
|
||||||
""" activitypub-aware text field """
|
"""activitypub-aware text field"""
|
||||||
|
|
||||||
|
|
||||||
class BooleanField(ActivitypubFieldMixin, models.BooleanField):
|
class BooleanField(ActivitypubFieldMixin, models.BooleanField):
|
||||||
""" activitypub-aware boolean field """
|
"""activitypub-aware boolean field"""
|
||||||
|
|
||||||
|
|
||||||
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
|
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
|
||||||
""" activitypub-aware boolean field """
|
"""activitypub-aware boolean field"""
|
||||||
|
|
||||||
|
|
||||||
class DecimalField(ActivitypubFieldMixin, models.DecimalField):
|
class DecimalField(ActivitypubFieldMixin, models.DecimalField):
|
||||||
""" activitypub-aware boolean field """
|
"""activitypub-aware boolean field"""
|
||||||
|
|
||||||
def field_to_activity(self, value):
|
def field_to_activity(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
|
|
|
@ -20,7 +20,7 @@ GOODREADS_SHELVES = {
|
||||||
|
|
||||||
|
|
||||||
def unquote_string(text):
|
def unquote_string(text):
|
||||||
""" resolve csv quote weirdness """
|
"""resolve csv quote weirdness"""
|
||||||
match = re.match(r'="([^"]*)"', text)
|
match = re.match(r'="([^"]*)"', text)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
|
@ -28,7 +28,7 @@ def unquote_string(text):
|
||||||
|
|
||||||
|
|
||||||
def construct_search_term(title, author):
|
def construct_search_term(title, author):
|
||||||
""" formulate a query for the data connector """
|
"""formulate a query for the data connector"""
|
||||||
# Strip brackets (usually series title from search term)
|
# Strip brackets (usually series title from search term)
|
||||||
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
|
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
|
||||||
# Open library doesn't like including author initials in search term.
|
# Open library doesn't like including author initials in search term.
|
||||||
|
@ -38,7 +38,7 @@ def construct_search_term(title, author):
|
||||||
|
|
||||||
|
|
||||||
class ImportJob(models.Model):
|
class ImportJob(models.Model):
|
||||||
""" entry for a specific request for book data import """
|
"""entry for a specific request for book data import"""
|
||||||
|
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
created_date = models.DateTimeField(default=timezone.now)
|
created_date = models.DateTimeField(default=timezone.now)
|
||||||
|
@ -51,7 +51,7 @@ class ImportJob(models.Model):
|
||||||
retry = models.BooleanField(default=False)
|
retry = models.BooleanField(default=False)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" save and notify """
|
"""save and notify"""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if self.complete:
|
if self.complete:
|
||||||
notification_model = apps.get_model(
|
notification_model = apps.get_model(
|
||||||
|
@ -65,7 +65,7 @@ class ImportJob(models.Model):
|
||||||
|
|
||||||
|
|
||||||
class ImportItem(models.Model):
|
class ImportItem(models.Model):
|
||||||
""" a single line of a csv being imported """
|
"""a single line of a csv being imported"""
|
||||||
|
|
||||||
job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
|
job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
|
||||||
index = models.IntegerField()
|
index = models.IntegerField()
|
||||||
|
@ -74,11 +74,11 @@ class ImportItem(models.Model):
|
||||||
fail_reason = models.TextField(null=True)
|
fail_reason = models.TextField(null=True)
|
||||||
|
|
||||||
def resolve(self):
|
def resolve(self):
|
||||||
""" try various ways to lookup a book """
|
"""try various ways to lookup a book"""
|
||||||
self.book = self.get_book_from_isbn() or self.get_book_from_title_author()
|
self.book = self.get_book_from_isbn() or self.get_book_from_title_author()
|
||||||
|
|
||||||
def get_book_from_isbn(self):
|
def get_book_from_isbn(self):
|
||||||
""" search by isbn """
|
"""search by isbn"""
|
||||||
search_result = connector_manager.first_search_result(
|
search_result = connector_manager.first_search_result(
|
||||||
self.isbn, min_confidence=0.999
|
self.isbn, min_confidence=0.999
|
||||||
)
|
)
|
||||||
|
@ -88,7 +88,7 @@ class ImportItem(models.Model):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_book_from_title_author(self):
|
def get_book_from_title_author(self):
|
||||||
""" search by title and author """
|
"""search by title and author"""
|
||||||
search_term = construct_search_term(self.title, self.author)
|
search_term = construct_search_term(self.title, self.author)
|
||||||
search_result = connector_manager.first_search_result(
|
search_result = connector_manager.first_search_result(
|
||||||
search_term, min_confidence=0.999
|
search_term, min_confidence=0.999
|
||||||
|
@ -100,60 +100,60 @@ class ImportItem(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def title(self):
|
def title(self):
|
||||||
""" get the book title """
|
"""get the book title"""
|
||||||
return self.data["Title"]
|
return self.data["Title"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def author(self):
|
def author(self):
|
||||||
""" get the book title """
|
"""get the book title"""
|
||||||
return self.data["Author"]
|
return self.data["Author"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isbn(self):
|
def isbn(self):
|
||||||
""" pulls out the isbn13 field from the csv line data """
|
"""pulls out the isbn13 field from the csv line data"""
|
||||||
return unquote_string(self.data["ISBN13"])
|
return unquote_string(self.data["ISBN13"])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def shelf(self):
|
def shelf(self):
|
||||||
""" the goodreads shelf field """
|
"""the goodreads shelf field"""
|
||||||
if self.data["Exclusive Shelf"]:
|
if self.data["Exclusive Shelf"]:
|
||||||
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
|
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def review(self):
|
def review(self):
|
||||||
""" a user-written review, to be imported with the book data """
|
"""a user-written review, to be imported with the book data"""
|
||||||
return self.data["My Review"]
|
return self.data["My Review"]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def rating(self):
|
def rating(self):
|
||||||
""" x/5 star rating for a book """
|
"""x/5 star rating for a book"""
|
||||||
return int(self.data["My Rating"])
|
return int(self.data["My Rating"])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def date_added(self):
|
def date_added(self):
|
||||||
""" when the book was added to this dataset """
|
"""when the book was added to this dataset"""
|
||||||
if self.data["Date Added"]:
|
if self.data["Date Added"]:
|
||||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
|
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def date_started(self):
|
def date_started(self):
|
||||||
""" when the book was started """
|
"""when the book was started"""
|
||||||
if "Date Started" in self.data and self.data["Date Started"]:
|
if "Date Started" in self.data and self.data["Date Started"]:
|
||||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
|
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def date_read(self):
|
def date_read(self):
|
||||||
""" the date a book was completed """
|
"""the date a book was completed"""
|
||||||
if self.data["Date Read"]:
|
if self.data["Date Read"]:
|
||||||
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
|
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def reads(self):
|
def reads(self):
|
||||||
""" formats a read through dataset for the book in this line """
|
"""formats a read through dataset for the book in this line"""
|
||||||
start_date = self.date_started
|
start_date = self.date_started
|
||||||
|
|
||||||
# Goodreads special case (no 'date started' field)
|
# Goodreads special case (no 'date started' field)
|
||||||
|
|
|
@ -21,7 +21,7 @@ CurationType = models.TextChoices(
|
||||||
|
|
||||||
|
|
||||||
class List(OrderedCollectionMixin, BookWyrmModel):
|
class List(OrderedCollectionMixin, BookWyrmModel):
|
||||||
""" a list of books """
|
"""a list of books"""
|
||||||
|
|
||||||
name = fields.CharField(max_length=100)
|
name = fields.CharField(max_length=100)
|
||||||
user = fields.ForeignKey(
|
user = fields.ForeignKey(
|
||||||
|
@ -41,43 +41,40 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
||||||
activity_serializer = activitypub.BookList
|
activity_serializer = activitypub.BookList
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
""" don't want the user to be in there in this case """
|
"""don't want the user to be in there in this case"""
|
||||||
return "https://%s/list/%d" % (DOMAIN, self.id)
|
return "https://%s/list/%d" % (DOMAIN, self.id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def collection_queryset(self):
|
def collection_queryset(self):
|
||||||
""" list of books for this shelf, overrides OrderedCollectionMixin """
|
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||||
return self.books.filter(listitem__approved=True).all().order_by("listitem")
|
return self.books.filter(listitem__approved=True).order_by("listitem")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" default sorting """
|
"""default sorting"""
|
||||||
|
|
||||||
ordering = ("-updated_date",)
|
ordering = ("-updated_date",)
|
||||||
|
|
||||||
|
|
||||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||||
""" ok """
|
"""ok"""
|
||||||
|
|
||||||
book = fields.ForeignKey(
|
book = fields.ForeignKey(
|
||||||
"Edition", on_delete=models.PROTECT, activitypub_field="object"
|
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||||
)
|
|
||||||
book_list = fields.ForeignKey(
|
|
||||||
"List", on_delete=models.CASCADE, activitypub_field="target"
|
|
||||||
)
|
)
|
||||||
|
book_list = models.ForeignKey("List", on_delete=models.CASCADE)
|
||||||
user = fields.ForeignKey(
|
user = fields.ForeignKey(
|
||||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||||
)
|
)
|
||||||
notes = fields.TextField(blank=True, null=True)
|
notes = fields.TextField(blank=True, null=True)
|
||||||
approved = models.BooleanField(default=True)
|
approved = models.BooleanField(default=True)
|
||||||
order = fields.IntegerField(blank=True, null=True)
|
order = fields.IntegerField()
|
||||||
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
||||||
|
|
||||||
activity_serializer = activitypub.Add
|
activity_serializer = activitypub.ListItem
|
||||||
object_field = "book"
|
|
||||||
collection_field = "book_list"
|
collection_field = "book_list"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" create a notification too """
|
"""create a notification too"""
|
||||||
created = not bool(self.id)
|
created = not bool(self.id)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
# tick the updated date on the parent list
|
# tick the updated date on the parent list
|
||||||
|
@ -96,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" an opinionated constraint! you can't put a book on a list twice """
|
# A book may only be placed into a list once, and each order in the list may be used only
|
||||||
|
# once
|
||||||
unique_together = ("book", "book_list")
|
unique_together = (("book", "book_list"), ("order", "book_list"))
|
||||||
ordering = ("-created_date",)
|
ordering = ("-created_date",)
|
||||||
|
|
|
@ -10,7 +10,7 @@ NotificationType = models.TextChoices(
|
||||||
|
|
||||||
|
|
||||||
class Notification(BookWyrmModel):
|
class Notification(BookWyrmModel):
|
||||||
""" you've been tagged, liked, followed, etc """
|
"""you've been tagged, liked, followed, etc"""
|
||||||
|
|
||||||
user = models.ForeignKey("User", on_delete=models.CASCADE)
|
user = models.ForeignKey("User", on_delete=models.CASCADE)
|
||||||
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
|
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
|
||||||
|
@ -29,7 +29,7 @@ class Notification(BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" save, but don't make dupes """
|
"""save, but don't make dupes"""
|
||||||
# there's probably a better way to do this
|
# there's probably a better way to do this
|
||||||
if self.__class__.objects.filter(
|
if self.__class__.objects.filter(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
|
@ -45,7 +45,7 @@ class Notification(BookWyrmModel):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" checks if notifcation is in enum list for valid types """
|
"""checks if notifcation is in enum list for valid types"""
|
||||||
|
|
||||||
constraints = [
|
constraints = [
|
||||||
models.CheckConstraint(
|
models.CheckConstraint(
|
||||||
|
|
|
@ -7,14 +7,14 @@ from .base_model import BookWyrmModel
|
||||||
|
|
||||||
|
|
||||||
class ProgressMode(models.TextChoices):
|
class ProgressMode(models.TextChoices):
|
||||||
""" types of prgress available """
|
"""types of prgress available"""
|
||||||
|
|
||||||
PAGE = "PG", "page"
|
PAGE = "PG", "page"
|
||||||
PERCENT = "PCT", "percent"
|
PERCENT = "PCT", "percent"
|
||||||
|
|
||||||
|
|
||||||
class ReadThrough(BookWyrmModel):
|
class ReadThrough(BookWyrmModel):
|
||||||
""" Store a read through a book in the database. """
|
"""Store a read through a book in the database."""
|
||||||
|
|
||||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
book = models.ForeignKey("Edition", on_delete=models.PROTECT)
|
book = models.ForeignKey("Edition", on_delete=models.PROTECT)
|
||||||
|
@ -28,13 +28,13 @@ class ReadThrough(BookWyrmModel):
|
||||||
finish_date = models.DateTimeField(blank=True, null=True)
|
finish_date = models.DateTimeField(blank=True, null=True)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" update user active time """
|
"""update user active time"""
|
||||||
self.user.last_active_date = timezone.now()
|
self.user.last_active_date = timezone.now()
|
||||||
self.user.save(broadcast=False)
|
self.user.save(broadcast=False)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def create_update(self):
|
def create_update(self):
|
||||||
""" add update to the readthrough """
|
"""add update to the readthrough"""
|
||||||
if self.progress:
|
if self.progress:
|
||||||
return self.progressupdate_set.create(
|
return self.progressupdate_set.create(
|
||||||
user=self.user, progress=self.progress, mode=self.progress_mode
|
user=self.user, progress=self.progress, mode=self.progress_mode
|
||||||
|
@ -43,7 +43,7 @@ class ReadThrough(BookWyrmModel):
|
||||||
|
|
||||||
|
|
||||||
class ProgressUpdate(BookWyrmModel):
|
class ProgressUpdate(BookWyrmModel):
|
||||||
""" Store progress through a book in the database. """
|
"""Store progress through a book in the database."""
|
||||||
|
|
||||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
|
readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
|
||||||
|
@ -53,7 +53,7 @@ class ProgressUpdate(BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" update user active time """
|
"""update user active time"""
|
||||||
self.user.last_active_date = timezone.now()
|
self.user.last_active_date = timezone.now()
|
||||||
self.user.save(broadcast=False)
|
self.user.save(broadcast=False)
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
|
@ -11,7 +11,7 @@ from . import fields
|
||||||
|
|
||||||
|
|
||||||
class UserRelationship(BookWyrmModel):
|
class UserRelationship(BookWyrmModel):
|
||||||
""" many-to-many through table for followers """
|
"""many-to-many through table for followers"""
|
||||||
|
|
||||||
user_subject = fields.ForeignKey(
|
user_subject = fields.ForeignKey(
|
||||||
"User",
|
"User",
|
||||||
|
@ -28,16 +28,16 @@ class UserRelationship(BookWyrmModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def privacy(self):
|
def privacy(self):
|
||||||
""" all relationships are handled directly with the participants """
|
"""all relationships are handled directly with the participants"""
|
||||||
return "direct"
|
return "direct"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recipients(self):
|
def recipients(self):
|
||||||
""" the remote user needs to recieve direct broadcasts """
|
"""the remote user needs to recieve direct broadcasts"""
|
||||||
return [u for u in [self.user_subject, self.user_object] if not u.local]
|
return [u for u in [self.user_subject, self.user_object] if not u.local]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" relationships should be unique """
|
"""relationships should be unique"""
|
||||||
|
|
||||||
abstract = True
|
abstract = True
|
||||||
constraints = [
|
constraints = [
|
||||||
|
@ -50,24 +50,23 @@ class UserRelationship(BookWyrmModel):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_remote_id(self, status=None): # pylint: disable=arguments-differ
|
def get_remote_id(self):
|
||||||
""" use shelf identifier in remote_id """
|
"""use shelf identifier in remote_id"""
|
||||||
status = status or "follows"
|
|
||||||
base_path = self.user_subject.remote_id
|
base_path = self.user_subject.remote_id
|
||||||
return "%s#%s/%d" % (base_path, status, self.id)
|
return "%s#follows/%d" % (base_path, self.id)
|
||||||
|
|
||||||
|
|
||||||
class UserFollows(ActivityMixin, UserRelationship):
|
class UserFollows(ActivityMixin, UserRelationship):
|
||||||
""" Following a user """
|
"""Following a user"""
|
||||||
|
|
||||||
status = "follows"
|
status = "follows"
|
||||||
|
|
||||||
def to_activity(self): # pylint: disable=arguments-differ
|
def to_activity(self): # pylint: disable=arguments-differ
|
||||||
""" overrides default to manually set serializer """
|
"""overrides default to manually set serializer"""
|
||||||
return activitypub.Follow(**generate_activity(self))
|
return activitypub.Follow(**generate_activity(self))
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" really really don't let a user follow someone who blocked them """
|
"""really really don't let a user follow someone who blocked them"""
|
||||||
# blocking in either direction is a no-go
|
# blocking in either direction is a no-go
|
||||||
if UserBlocks.objects.filter(
|
if UserBlocks.objects.filter(
|
||||||
Q(
|
Q(
|
||||||
|
@ -86,7 +85,7 @@ class UserFollows(ActivityMixin, UserRelationship):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_request(cls, follow_request):
|
def from_request(cls, follow_request):
|
||||||
""" converts a follow request into a follow relationship """
|
"""converts a follow request into a follow relationship"""
|
||||||
return cls.objects.create(
|
return cls.objects.create(
|
||||||
user_subject=follow_request.user_subject,
|
user_subject=follow_request.user_subject,
|
||||||
user_object=follow_request.user_object,
|
user_object=follow_request.user_object,
|
||||||
|
@ -95,19 +94,22 @@ class UserFollows(ActivityMixin, UserRelationship):
|
||||||
|
|
||||||
|
|
||||||
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
""" following a user requires manual or automatic confirmation """
|
"""following a user requires manual or automatic confirmation"""
|
||||||
|
|
||||||
status = "follow_request"
|
status = "follow_request"
|
||||||
activity_serializer = activitypub.Follow
|
activity_serializer = activitypub.Follow
|
||||||
|
|
||||||
def save(self, *args, broadcast=True, **kwargs):
|
def save(self, *args, broadcast=True, **kwargs):
|
||||||
""" make sure the follow or block relationship doesn't already exist """
|
"""make sure the follow or block relationship doesn't already exist"""
|
||||||
# don't create a request if a follow already exists
|
# if there's a request for a follow that already exists, accept it
|
||||||
|
# without changing the local database state
|
||||||
if UserFollows.objects.filter(
|
if UserFollows.objects.filter(
|
||||||
user_subject=self.user_subject,
|
user_subject=self.user_subject,
|
||||||
user_object=self.user_object,
|
user_object=self.user_object,
|
||||||
).exists():
|
).exists():
|
||||||
raise IntegrityError()
|
self.accept(broadcast_only=True)
|
||||||
|
return
|
||||||
|
|
||||||
# blocking in either direction is a no-go
|
# blocking in either direction is a no-go
|
||||||
if UserBlocks.objects.filter(
|
if UserBlocks.objects.filter(
|
||||||
Q(
|
Q(
|
||||||
|
@ -138,25 +140,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
notification_type=notification_type,
|
notification_type=notification_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
def accept(self):
|
def get_accept_reject_id(self, status):
|
||||||
""" turn this request into the real deal"""
|
"""get id for sending an accept or reject of a local user"""
|
||||||
|
|
||||||
|
base_path = self.user_object.remote_id
|
||||||
|
return "%s#%s/%d" % (base_path, status, self.id or 0)
|
||||||
|
|
||||||
|
def accept(self, broadcast_only=False):
|
||||||
|
"""turn this request into the real deal"""
|
||||||
user = self.user_object
|
user = self.user_object
|
||||||
if not self.user_subject.local:
|
if not self.user_subject.local:
|
||||||
activity = activitypub.Accept(
|
activity = activitypub.Accept(
|
||||||
id=self.get_remote_id(status="accepts"),
|
id=self.get_accept_reject_id(status="accepts"),
|
||||||
actor=self.user_object.remote_id,
|
actor=self.user_object.remote_id,
|
||||||
object=self.to_activity(),
|
object=self.to_activity(),
|
||||||
).serialize()
|
).serialize()
|
||||||
self.broadcast(activity, user)
|
self.broadcast(activity, user)
|
||||||
|
if broadcast_only:
|
||||||
|
return
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
UserFollows.from_request(self)
|
UserFollows.from_request(self)
|
||||||
self.delete()
|
self.delete()
|
||||||
|
|
||||||
def reject(self):
|
def reject(self):
|
||||||
""" generate a Reject for this follow request """
|
"""generate a Reject for this follow request"""
|
||||||
if self.user_object.local:
|
if self.user_object.local:
|
||||||
activity = activitypub.Reject(
|
activity = activitypub.Reject(
|
||||||
id=self.get_remote_id(status="rejects"),
|
id=self.get_accept_reject_id(status="rejects"),
|
||||||
actor=self.user_object.remote_id,
|
actor=self.user_object.remote_id,
|
||||||
object=self.to_activity(),
|
object=self.to_activity(),
|
||||||
).serialize()
|
).serialize()
|
||||||
|
@ -166,13 +177,13 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
|
|
||||||
|
|
||||||
class UserBlocks(ActivityMixin, UserRelationship):
|
class UserBlocks(ActivityMixin, UserRelationship):
|
||||||
""" prevent another user from following you and seeing your posts """
|
"""prevent another user from following you and seeing your posts"""
|
||||||
|
|
||||||
status = "blocks"
|
status = "blocks"
|
||||||
activity_serializer = activitypub.Block
|
activity_serializer = activitypub.Block
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" remove follow or follow request rels after a block is created """
|
"""remove follow or follow request rels after a block is created"""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
UserFollows.objects.filter(
|
UserFollows.objects.filter(
|
||||||
|
|
|
@ -6,7 +6,7 @@ from .base_model import BookWyrmModel
|
||||||
|
|
||||||
|
|
||||||
class Report(BookWyrmModel):
|
class Report(BookWyrmModel):
|
||||||
""" reported status or user """
|
"""reported status or user"""
|
||||||
|
|
||||||
reporter = models.ForeignKey(
|
reporter = models.ForeignKey(
|
||||||
"User", related_name="reporter", on_delete=models.PROTECT
|
"User", related_name="reporter", on_delete=models.PROTECT
|
||||||
|
@ -17,7 +17,7 @@ class Report(BookWyrmModel):
|
||||||
resolved = models.BooleanField(default=False)
|
resolved = models.BooleanField(default=False)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" notify admins when a report is created """
|
"""notify admins when a report is created"""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||||
# moderators and superusers should be notified
|
# moderators and superusers should be notified
|
||||||
|
@ -34,7 +34,7 @@ class Report(BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" don't let users report themselves """
|
"""don't let users report themselves"""
|
||||||
|
|
||||||
constraints = [
|
constraints = [
|
||||||
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
|
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
|
||||||
|
@ -43,13 +43,13 @@ class Report(BookWyrmModel):
|
||||||
|
|
||||||
|
|
||||||
class ReportComment(BookWyrmModel):
|
class ReportComment(BookWyrmModel):
|
||||||
""" updates on a report """
|
"""updates on a report"""
|
||||||
|
|
||||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
note = models.TextField()
|
note = models.TextField()
|
||||||
report = models.ForeignKey(Report, on_delete=models.PROTECT)
|
report = models.ForeignKey(Report, on_delete=models.PROTECT)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" sort comments """
|
"""sort comments"""
|
||||||
|
|
||||||
ordering = ("-created_date",)
|
ordering = ("-created_date",)
|
||||||
|
|
|
@ -9,7 +9,7 @@ from . import fields
|
||||||
|
|
||||||
|
|
||||||
class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||||
""" a list of books owned by a user """
|
"""a list of books owned by a user"""
|
||||||
|
|
||||||
TO_READ = "to-read"
|
TO_READ = "to-read"
|
||||||
READING = "reading"
|
READING = "reading"
|
||||||
|
@ -34,49 +34,46 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||||
activity_serializer = activitypub.Shelf
|
activity_serializer = activitypub.Shelf
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" set the identifier """
|
"""set the identifier"""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if not self.identifier:
|
if not self.identifier:
|
||||||
self.identifier = self.get_identifier()
|
self.identifier = self.get_identifier()
|
||||||
super().save(*args, **kwargs, broadcast=False)
|
super().save(*args, **kwargs, broadcast=False)
|
||||||
|
|
||||||
def get_identifier(self):
|
def get_identifier(self):
|
||||||
""" custom-shelf-123 for the url """
|
"""custom-shelf-123 for the url"""
|
||||||
slug = re.sub(r"[^\w]", "", self.name).lower()
|
slug = re.sub(r"[^\w]", "", self.name).lower()
|
||||||
return "{:s}-{:d}".format(slug, self.id)
|
return "{:s}-{:d}".format(slug, self.id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def collection_queryset(self):
|
def collection_queryset(self):
|
||||||
""" list of books for this shelf, overrides OrderedCollectionMixin """
|
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||||
return self.books.all().order_by("shelfbook")
|
return self.books.order_by("shelfbook")
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
""" shelf identifier instead of id """
|
"""shelf identifier instead of id"""
|
||||||
base_path = self.user.remote_id
|
base_path = self.user.remote_id
|
||||||
identifier = self.identifier or self.get_identifier()
|
identifier = self.identifier or self.get_identifier()
|
||||||
return "%s/books/%s" % (base_path, identifier)
|
return "%s/books/%s" % (base_path, identifier)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" user/shelf unqiueness """
|
"""user/shelf unqiueness"""
|
||||||
|
|
||||||
unique_together = ("user", "identifier")
|
unique_together = ("user", "identifier")
|
||||||
|
|
||||||
|
|
||||||
class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
||||||
""" many to many join table for books and shelves """
|
"""many to many join table for books and shelves"""
|
||||||
|
|
||||||
book = fields.ForeignKey(
|
book = fields.ForeignKey(
|
||||||
"Edition", on_delete=models.PROTECT, activitypub_field="object"
|
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||||
)
|
|
||||||
shelf = fields.ForeignKey(
|
|
||||||
"Shelf", on_delete=models.PROTECT, activitypub_field="target"
|
|
||||||
)
|
)
|
||||||
|
shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT)
|
||||||
user = fields.ForeignKey(
|
user = fields.ForeignKey(
|
||||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||||
)
|
)
|
||||||
|
|
||||||
activity_serializer = activitypub.Add
|
activity_serializer = activitypub.ShelfItem
|
||||||
object_field = "book"
|
|
||||||
collection_field = "shelf"
|
collection_field = "shelf"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|
|
@ -12,7 +12,7 @@ from .user import User
|
||||||
|
|
||||||
|
|
||||||
class SiteSettings(models.Model):
|
class SiteSettings(models.Model):
|
||||||
""" customized settings for this instance """
|
"""customized settings for this instance"""
|
||||||
|
|
||||||
name = models.CharField(default="BookWyrm", max_length=100)
|
name = models.CharField(default="BookWyrm", max_length=100)
|
||||||
instance_tagline = models.CharField(
|
instance_tagline = models.CharField(
|
||||||
|
@ -35,7 +35,7 @@ class SiteSettings(models.Model):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls):
|
def get(cls):
|
||||||
""" gets the site settings db entry or defaults """
|
"""gets the site settings db entry or defaults"""
|
||||||
try:
|
try:
|
||||||
return cls.objects.get(id=1)
|
return cls.objects.get(id=1)
|
||||||
except cls.DoesNotExist:
|
except cls.DoesNotExist:
|
||||||
|
@ -45,12 +45,12 @@ class SiteSettings(models.Model):
|
||||||
|
|
||||||
|
|
||||||
def new_access_code():
|
def new_access_code():
|
||||||
""" the identifier for a user invite """
|
"""the identifier for a user invite"""
|
||||||
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
|
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
class SiteInvite(models.Model):
|
class SiteInvite(models.Model):
|
||||||
""" gives someone access to create an account on the instance """
|
"""gives someone access to create an account on the instance"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
code = models.CharField(max_length=32, default=new_access_code)
|
code = models.CharField(max_length=32, default=new_access_code)
|
||||||
|
@ -61,19 +61,19 @@ class SiteInvite(models.Model):
|
||||||
invitees = models.ManyToManyField(User, related_name="invitees")
|
invitees = models.ManyToManyField(User, related_name="invitees")
|
||||||
|
|
||||||
def valid(self):
|
def valid(self):
|
||||||
""" make sure it hasn't expired or been used """
|
"""make sure it hasn't expired or been used"""
|
||||||
return (self.expiry is None or self.expiry > timezone.now()) and (
|
return (self.expiry is None or self.expiry > timezone.now()) and (
|
||||||
self.use_limit is None or self.times_used < self.use_limit
|
self.use_limit is None or self.times_used < self.use_limit
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def link(self):
|
def link(self):
|
||||||
""" formats the invite link """
|
"""formats the invite link"""
|
||||||
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
||||||
|
|
||||||
|
|
||||||
class InviteRequest(BookWyrmModel):
|
class InviteRequest(BookWyrmModel):
|
||||||
""" prospective users can request an invite """
|
"""prospective users can request an invite"""
|
||||||
|
|
||||||
email = models.EmailField(max_length=255, unique=True)
|
email = models.EmailField(max_length=255, unique=True)
|
||||||
invite = models.ForeignKey(
|
invite = models.ForeignKey(
|
||||||
|
@ -83,30 +83,30 @@ class InviteRequest(BookWyrmModel):
|
||||||
ignored = models.BooleanField(default=False)
|
ignored = models.BooleanField(default=False)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" don't create a request for a registered email """
|
"""don't create a request for a registered email"""
|
||||||
if not self.id and User.objects.filter(email=self.email).exists():
|
if not self.id and User.objects.filter(email=self.email).exists():
|
||||||
raise IntegrityError()
|
raise IntegrityError()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_passowrd_reset_expiry():
|
def get_passowrd_reset_expiry():
|
||||||
""" give people a limited time to use the link """
|
"""give people a limited time to use the link"""
|
||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
return now + datetime.timedelta(days=1)
|
return now + datetime.timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
class PasswordReset(models.Model):
|
class PasswordReset(models.Model):
|
||||||
""" gives someone access to create an account on the instance """
|
"""gives someone access to create an account on the instance"""
|
||||||
|
|
||||||
code = models.CharField(max_length=32, default=new_access_code)
|
code = models.CharField(max_length=32, default=new_access_code)
|
||||||
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
|
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
|
|
||||||
def valid(self):
|
def valid(self):
|
||||||
""" make sure it hasn't expired or been used """
|
"""make sure it hasn't expired or been used"""
|
||||||
return self.expiry > timezone.now()
|
return self.expiry > timezone.now()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def link(self):
|
def link(self):
|
||||||
""" formats the invite link """
|
"""formats the invite link"""
|
||||||
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
||||||
|
|
|
@ -19,7 +19,7 @@ from . import fields
|
||||||
|
|
||||||
|
|
||||||
class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
""" any post, like a reply to a review, etc """
|
"""any post, like a reply to a review, etc"""
|
||||||
|
|
||||||
user = fields.ForeignKey(
|
user = fields.ForeignKey(
|
||||||
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
|
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
|
||||||
|
@ -59,12 +59,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
deserialize_reverse_fields = [("attachments", "attachment")]
|
deserialize_reverse_fields = [("attachments", "attachment")]
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" default sorting """
|
"""default sorting"""
|
||||||
|
|
||||||
ordering = ("-published_date",)
|
ordering = ("-published_date",)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" save and notify """
|
"""save and notify"""
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||||
|
@ -98,7 +98,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||||
""" "delete" a status """
|
""" "delete" a status"""
|
||||||
if hasattr(self, "boosted_status"):
|
if hasattr(self, "boosted_status"):
|
||||||
# okay but if it's a boost really delete it
|
# okay but if it's a boost really delete it
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
|
@ -109,7 +109,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recipients(self):
|
def recipients(self):
|
||||||
""" tagged users who definitely need to get this status in broadcast """
|
"""tagged users who definitely need to get this status in broadcast"""
|
||||||
mentions = [u for u in self.mention_users.all() if not u.local]
|
mentions = [u for u in self.mention_users.all() if not u.local]
|
||||||
if (
|
if (
|
||||||
hasattr(self, "reply_parent")
|
hasattr(self, "reply_parent")
|
||||||
|
@ -121,7 +121,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
|
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
|
||||||
""" keep notes if they are replies to existing statuses """
|
"""keep notes if they are replies to existing statuses"""
|
||||||
if activity.type == "Announce":
|
if activity.type == "Announce":
|
||||||
try:
|
try:
|
||||||
boosted = activitypub.resolve_remote_id(
|
boosted = activitypub.resolve_remote_id(
|
||||||
|
@ -163,16 +163,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status_type(self):
|
def status_type(self):
|
||||||
""" expose the type of status for the ui using activity type """
|
"""expose the type of status for the ui using activity type"""
|
||||||
return self.activity_serializer.__name__
|
return self.activity_serializer.__name__
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def boostable(self):
|
def boostable(self):
|
||||||
""" you can't boost dms """
|
"""you can't boost dms"""
|
||||||
return self.privacy in ["unlisted", "public"]
|
return self.privacy in ["unlisted", "public"]
|
||||||
|
|
||||||
def to_replies(self, **kwargs):
|
def to_replies(self, **kwargs):
|
||||||
""" helper function for loading AP serialized replies to a status """
|
"""helper function for loading AP serialized replies to a status"""
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
self.replies(self),
|
self.replies(self),
|
||||||
remote_id="%s/replies" % self.remote_id,
|
remote_id="%s/replies" % self.remote_id,
|
||||||
|
@ -181,7 +181,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
).serialize()
|
).serialize()
|
||||||
|
|
||||||
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
|
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
|
||||||
""" return tombstone if the status is deleted """
|
"""return tombstone if the status is deleted"""
|
||||||
if self.deleted:
|
if self.deleted:
|
||||||
return activitypub.Tombstone(
|
return activitypub.Tombstone(
|
||||||
id=self.remote_id,
|
id=self.remote_id,
|
||||||
|
@ -210,16 +210,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
return activity
|
return activity
|
||||||
|
|
||||||
def to_activity(self, pure=False): # pylint: disable=arguments-differ
|
def to_activity(self, pure=False): # pylint: disable=arguments-differ
|
||||||
""" json serialized activitypub class """
|
"""json serialized activitypub class"""
|
||||||
return self.to_activity_dataclass(pure=pure).serialize()
|
return self.to_activity_dataclass(pure=pure).serialize()
|
||||||
|
|
||||||
|
|
||||||
class GeneratedNote(Status):
|
class GeneratedNote(Status):
|
||||||
""" these are app-generated messages about user activity """
|
"""these are app-generated messages about user activity"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
""" indicate the book in question for mastodon (or w/e) users """
|
"""indicate the book in question for mastodon (or w/e) users"""
|
||||||
message = self.content
|
message = self.content
|
||||||
books = ", ".join(
|
books = ", ".join(
|
||||||
'<a href="%s">"%s"</a>' % (book.remote_id, book.title)
|
'<a href="%s">"%s"</a>' % (book.remote_id, book.title)
|
||||||
|
@ -232,7 +232,7 @@ class GeneratedNote(Status):
|
||||||
|
|
||||||
|
|
||||||
class Comment(Status):
|
class Comment(Status):
|
||||||
""" like a review but without a rating and transient """
|
"""like a review but without a rating and transient"""
|
||||||
|
|
||||||
book = fields.ForeignKey(
|
book = fields.ForeignKey(
|
||||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||||
|
@ -253,7 +253,7 @@ class Comment(Status):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
""" indicate the book in question for mastodon (or w/e) users """
|
"""indicate the book in question for mastodon (or w/e) users"""
|
||||||
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
|
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
|
||||||
self.content,
|
self.content,
|
||||||
self.book.remote_id,
|
self.book.remote_id,
|
||||||
|
@ -265,7 +265,7 @@ class Comment(Status):
|
||||||
|
|
||||||
|
|
||||||
class Quotation(Status):
|
class Quotation(Status):
|
||||||
""" like a review but without a rating and transient """
|
"""like a review but without a rating and transient"""
|
||||||
|
|
||||||
quote = fields.HtmlField()
|
quote = fields.HtmlField()
|
||||||
book = fields.ForeignKey(
|
book = fields.ForeignKey(
|
||||||
|
@ -274,7 +274,7 @@ class Quotation(Status):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
""" indicate the book in question for mastodon (or w/e) users """
|
"""indicate the book in question for mastodon (or w/e) users"""
|
||||||
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
||||||
quote = re.sub(r"</p>$", '"</p>', quote)
|
quote = re.sub(r"</p>$", '"</p>', quote)
|
||||||
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
|
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
|
||||||
|
@ -289,7 +289,7 @@ class Quotation(Status):
|
||||||
|
|
||||||
|
|
||||||
class Review(Status):
|
class Review(Status):
|
||||||
""" a book review """
|
"""a book review"""
|
||||||
|
|
||||||
name = fields.CharField(max_length=255, null=True)
|
name = fields.CharField(max_length=255, null=True)
|
||||||
book = fields.ForeignKey(
|
book = fields.ForeignKey(
|
||||||
|
@ -306,7 +306,7 @@ class Review(Status):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pure_name(self):
|
def pure_name(self):
|
||||||
""" clarify review names for mastodon serialization """
|
"""clarify review names for mastodon serialization"""
|
||||||
template = get_template("snippets/generated_status/review_pure_name.html")
|
template = get_template("snippets/generated_status/review_pure_name.html")
|
||||||
return template.render(
|
return template.render(
|
||||||
{"book": self.book, "rating": self.rating, "name": self.name}
|
{"book": self.book, "rating": self.rating, "name": self.name}
|
||||||
|
@ -314,7 +314,7 @@ class Review(Status):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pure_content(self):
|
def pure_content(self):
|
||||||
""" indicate the book in question for mastodon (or w/e) users """
|
"""indicate the book in question for mastodon (or w/e) users"""
|
||||||
return self.content
|
return self.content
|
||||||
|
|
||||||
activity_serializer = activitypub.Review
|
activity_serializer = activitypub.Review
|
||||||
|
@ -322,7 +322,7 @@ class Review(Status):
|
||||||
|
|
||||||
|
|
||||||
class ReviewRating(Review):
|
class ReviewRating(Review):
|
||||||
""" a subtype of review that only contains a rating """
|
"""a subtype of review that only contains a rating"""
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self.rating:
|
if not self.rating:
|
||||||
|
@ -339,7 +339,7 @@ class ReviewRating(Review):
|
||||||
|
|
||||||
|
|
||||||
class Boost(ActivityMixin, Status):
|
class Boost(ActivityMixin, Status):
|
||||||
""" boost'ing a post """
|
"""boost'ing a post"""
|
||||||
|
|
||||||
boosted_status = fields.ForeignKey(
|
boosted_status = fields.ForeignKey(
|
||||||
"Status",
|
"Status",
|
||||||
|
@ -350,7 +350,17 @@ class Boost(ActivityMixin, Status):
|
||||||
activity_serializer = activitypub.Announce
|
activity_serializer = activitypub.Announce
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" save and notify """
|
"""save and notify"""
|
||||||
|
# This constraint can't work as it would cross tables.
|
||||||
|
# class Meta:
|
||||||
|
# unique_together = ('user', 'boosted_status')
|
||||||
|
if (
|
||||||
|
Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
|
||||||
|
.exclude(id=self.id)
|
||||||
|
.exists()
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
|
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
|
||||||
return
|
return
|
||||||
|
@ -364,7 +374,7 @@ class Boost(ActivityMixin, Status):
|
||||||
)
|
)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
""" delete and un-notify """
|
"""delete and un-notify"""
|
||||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||||
notification_model.objects.filter(
|
notification_model.objects.filter(
|
||||||
user=self.boosted_status.user,
|
user=self.boosted_status.user,
|
||||||
|
@ -375,7 +385,7 @@ class Boost(ActivityMixin, Status):
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
""" the user field is "actor" here instead of "attributedTo" """
|
"""the user field is "actor" here instead of "attributedTo" """
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
reserve_fields = ["user", "boosted_status", "published_date", "privacy"]
|
reserve_fields = ["user", "boosted_status", "published_date", "privacy"]
|
||||||
|
|
|
@ -1,63 +0,0 @@
|
||||||
""" models for storing different kinds of Activities """
|
|
||||||
import urllib.parse
|
|
||||||
|
|
||||||
from django.apps import apps
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
|
||||||
from bookwyrm.settings import DOMAIN
|
|
||||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
|
||||||
from .base_model import BookWyrmModel
|
|
||||||
from . import fields
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(OrderedCollectionMixin, BookWyrmModel):
|
|
||||||
""" freeform tags for books """
|
|
||||||
|
|
||||||
name = fields.CharField(max_length=100, unique=True)
|
|
||||||
identifier = models.CharField(max_length=100)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def books(self):
|
|
||||||
""" count of books associated with this tag """
|
|
||||||
edition_model = apps.get_model("bookwyrm.Edition", require_ready=True)
|
|
||||||
return (
|
|
||||||
edition_model.objects.filter(usertag__tag__identifier=self.identifier)
|
|
||||||
.order_by("-created_date")
|
|
||||||
.distinct()
|
|
||||||
)
|
|
||||||
|
|
||||||
collection_queryset = books
|
|
||||||
|
|
||||||
def get_remote_id(self):
|
|
||||||
""" tag should use identifier not id in remote_id """
|
|
||||||
base_path = "https://%s" % DOMAIN
|
|
||||||
return "%s/tag/%s" % (base_path, self.identifier)
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
|
||||||
""" create a url-safe lookup key for the tag """
|
|
||||||
if not self.id:
|
|
||||||
# add identifiers to new tags
|
|
||||||
self.identifier = urllib.parse.quote_plus(self.name)
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class UserTag(CollectionItemMixin, BookWyrmModel):
|
|
||||||
""" an instance of a tag on a book by a user """
|
|
||||||
|
|
||||||
user = fields.ForeignKey(
|
|
||||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
|
||||||
)
|
|
||||||
book = fields.ForeignKey(
|
|
||||||
"Edition", on_delete=models.PROTECT, activitypub_field="object"
|
|
||||||
)
|
|
||||||
tag = fields.ForeignKey("Tag", on_delete=models.PROTECT, activitypub_field="target")
|
|
||||||
|
|
||||||
activity_serializer = activitypub.Add
|
|
||||||
object_field = "book"
|
|
||||||
collection_field = "tag"
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
""" unqiueness constraint """
|
|
||||||
|
|
||||||
unique_together = ("user", "book", "tag")
|
|
|
@ -4,6 +4,7 @@ from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import AbstractUser, Group
|
from django.contrib.auth.models import AbstractUser, Group
|
||||||
|
from django.contrib.postgres.fields import CICharField
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -23,8 +24,18 @@ from .federated_server import FederatedServer
|
||||||
from . import fields, Review
|
from . import fields, Review
|
||||||
|
|
||||||
|
|
||||||
|
DeactivationReason = models.TextChoices(
|
||||||
|
"DeactivationReason",
|
||||||
|
[
|
||||||
|
"self_deletion",
|
||||||
|
"moderator_deletion",
|
||||||
|
"domain_block",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
""" a user who wants to read books """
|
"""a user who wants to read books"""
|
||||||
|
|
||||||
username = fields.UsernameField()
|
username = fields.UsernameField()
|
||||||
email = models.EmailField(unique=True, null=True)
|
email = models.EmailField(unique=True, null=True)
|
||||||
|
@ -54,7 +65,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
summary = fields.HtmlField(null=True, blank=True)
|
summary = fields.HtmlField(null=True, blank=True)
|
||||||
local = models.BooleanField(default=False)
|
local = models.BooleanField(default=False)
|
||||||
bookwyrm_user = fields.BooleanField(default=True)
|
bookwyrm_user = fields.BooleanField(default=True)
|
||||||
localname = models.CharField(
|
localname = CICharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
null=True,
|
null=True,
|
||||||
unique=True,
|
unique=True,
|
||||||
|
@ -110,33 +121,47 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
default=str(pytz.utc),
|
default=str(pytz.utc),
|
||||||
max_length=255,
|
max_length=255,
|
||||||
)
|
)
|
||||||
|
deactivation_reason = models.CharField(
|
||||||
|
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
name_field = "username"
|
name_field = "username"
|
||||||
|
property_fields = [("following_link", "following")]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def following_link(self):
|
||||||
|
"""just how to find out the following info"""
|
||||||
|
return "{:s}/following".format(self.remote_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def alt_text(self):
|
def alt_text(self):
|
||||||
""" alt text with username """
|
"""alt text with username"""
|
||||||
return "avatar for %s" % (self.localname or self.username)
|
return "avatar for %s" % (self.localname or self.username)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def display_name(self):
|
def display_name(self):
|
||||||
""" show the cleanest version of the user's name possible """
|
"""show the cleanest version of the user's name possible"""
|
||||||
if self.name and self.name != "":
|
if self.name and self.name != "":
|
||||||
return self.name
|
return self.name
|
||||||
return self.localname or self.username
|
return self.localname or self.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deleted(self):
|
||||||
|
"""for consistent naming"""
|
||||||
|
return not self.is_active
|
||||||
|
|
||||||
activity_serializer = activitypub.Person
|
activity_serializer = activitypub.Person
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def viewer_aware_objects(cls, viewer):
|
def viewer_aware_objects(cls, viewer):
|
||||||
""" the user queryset filtered for the context of the logged in user """
|
"""the user queryset filtered for the context of the logged in user"""
|
||||||
queryset = cls.objects.filter(is_active=True)
|
queryset = cls.objects.filter(is_active=True)
|
||||||
if viewer.is_authenticated:
|
if viewer and viewer.is_authenticated:
|
||||||
queryset = queryset.exclude(blocks=viewer)
|
queryset = queryset.exclude(blocks=viewer)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def to_outbox(self, filter_type=None, **kwargs):
|
def to_outbox(self, filter_type=None, **kwargs):
|
||||||
""" an ordered collection of statuses """
|
"""an ordered collection of statuses"""
|
||||||
if filter_type:
|
if filter_type:
|
||||||
filter_class = apps.get_model(
|
filter_class = apps.get_model(
|
||||||
"bookwyrm.%s" % filter_type, require_ready=True
|
"bookwyrm.%s" % filter_type, require_ready=True
|
||||||
|
@ -163,7 +188,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
).serialize()
|
).serialize()
|
||||||
|
|
||||||
def to_following_activity(self, **kwargs):
|
def to_following_activity(self, **kwargs):
|
||||||
""" activitypub following list """
|
"""activitypub following list"""
|
||||||
remote_id = "%s/following" % self.remote_id
|
remote_id = "%s/following" % self.remote_id
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
self.following.order_by("-updated_date").all(),
|
self.following.order_by("-updated_date").all(),
|
||||||
|
@ -173,7 +198,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_followers_activity(self, **kwargs):
|
def to_followers_activity(self, **kwargs):
|
||||||
""" activitypub followers list """
|
"""activitypub followers list"""
|
||||||
remote_id = "%s/followers" % self.remote_id
|
remote_id = "%s/followers" % self.remote_id
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
self.followers.order_by("-updated_date").all(),
|
self.followers.order_by("-updated_date").all(),
|
||||||
|
@ -185,6 +210,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
def to_activity(self, **kwargs):
|
def to_activity(self, **kwargs):
|
||||||
"""override default AP serializer to add context object
|
"""override default AP serializer to add context object
|
||||||
idk if this is the best way to go about this"""
|
idk if this is the best way to go about this"""
|
||||||
|
if not self.is_active:
|
||||||
|
return self.remote_id
|
||||||
|
|
||||||
activity_object = super().to_activity(**kwargs)
|
activity_object = super().to_activity(**kwargs)
|
||||||
activity_object["@context"] = [
|
activity_object["@context"] = [
|
||||||
"https://www.w3.org/ns/activitystreams",
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -199,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
return activity_object
|
return activity_object
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" populate fields for new local users """
|
"""populate fields for new local users"""
|
||||||
created = not bool(self.id)
|
created = not bool(self.id)
|
||||||
if not self.local and not re.match(regex.full_username, self.username):
|
if not self.local and not re.match(regex.full_username, self.username):
|
||||||
# generate a username that uses the domain (webfinger format)
|
# generate a username that uses the domain (webfinger format)
|
||||||
|
@ -263,14 +291,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
editable=False,
|
editable=False,
|
||||||
).save(broadcast=False)
|
).save(broadcast=False)
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
"""deactivate rather than delete a user"""
|
||||||
|
self.is_active = False
|
||||||
|
# skip the logic in this class's save()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def local_path(self):
|
def local_path(self):
|
||||||
""" this model doesn't inherit bookwyrm model, so here we are """
|
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||||
return "/user/%s" % (self.localname or self.username)
|
return "/user/%s" % (self.localname or self.username)
|
||||||
|
|
||||||
|
|
||||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
""" public and private keys for a user """
|
"""public and private keys for a user"""
|
||||||
|
|
||||||
private_key = models.TextField(blank=True, null=True)
|
private_key = models.TextField(blank=True, null=True)
|
||||||
public_key = fields.TextField(
|
public_key = fields.TextField(
|
||||||
|
@ -285,7 +319,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
return "%s/#main-key" % self.owner.remote_id
|
return "%s/#main-key" % self.owner.remote_id
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
""" create a key pair """
|
"""create a key pair"""
|
||||||
# no broadcasting happening here
|
# no broadcasting happening here
|
||||||
if "broadcast" in kwargs:
|
if "broadcast" in kwargs:
|
||||||
del kwargs["broadcast"]
|
del kwargs["broadcast"]
|
||||||
|
@ -303,7 +337,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
|
|
||||||
|
|
||||||
class AnnualGoal(BookWyrmModel):
|
class AnnualGoal(BookWyrmModel):
|
||||||
""" set a goal for how many books you read in a year """
|
"""set a goal for how many books you read in a year"""
|
||||||
|
|
||||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
goal = models.IntegerField(validators=[MinValueValidator(1)])
|
goal = models.IntegerField(validators=[MinValueValidator(1)])
|
||||||
|
@ -313,17 +347,17 @@ class AnnualGoal(BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
""" unqiueness constraint """
|
"""unqiueness constraint"""
|
||||||
|
|
||||||
unique_together = ("user", "year")
|
unique_together = ("user", "year")
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
""" put the year in the path """
|
"""put the year in the path"""
|
||||||
return "%s/goal/%d" % (self.user.remote_id, self.year)
|
return "%s/goal/%d" % (self.user.remote_id, self.year)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def books(self):
|
def books(self):
|
||||||
""" the books you've read this year """
|
"""the books you've read this year"""
|
||||||
return (
|
return (
|
||||||
self.user.readthrough_set.filter(finish_date__year__gte=self.year)
|
self.user.readthrough_set.filter(finish_date__year__gte=self.year)
|
||||||
.order_by("-finish_date")
|
.order_by("-finish_date")
|
||||||
|
@ -332,7 +366,7 @@ class AnnualGoal(BookWyrmModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ratings(self):
|
def ratings(self):
|
||||||
""" ratings for books read this year """
|
"""ratings for books read this year"""
|
||||||
book_ids = [r.book.id for r in self.books]
|
book_ids = [r.book.id for r in self.books]
|
||||||
reviews = Review.objects.filter(
|
reviews = Review.objects.filter(
|
||||||
user=self.user,
|
user=self.user,
|
||||||
|
@ -342,12 +376,12 @@ class AnnualGoal(BookWyrmModel):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def progress_percent(self):
|
def progress_percent(self):
|
||||||
""" how close to your goal, in percent form """
|
"""how close to your goal, in percent form"""
|
||||||
return int(float(self.book_count / self.goal) * 100)
|
return int(float(self.book_count / self.goal) * 100)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def book_count(self):
|
def book_count(self):
|
||||||
""" how many books you've read this year """
|
"""how many books you've read this year"""
|
||||||
return self.user.readthrough_set.filter(
|
return self.user.readthrough_set.filter(
|
||||||
finish_date__year__gte=self.year
|
finish_date__year__gte=self.year
|
||||||
).count()
|
).count()
|
||||||
|
@ -355,7 +389,7 @@ class AnnualGoal(BookWyrmModel):
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def set_remote_server(user_id):
|
def set_remote_server(user_id):
|
||||||
""" figure out the user's remote server in the background """
|
"""figure out the user's remote server in the background"""
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
actor_parts = urlparse(user.remote_id)
|
actor_parts = urlparse(user.remote_id)
|
||||||
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
|
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
|
||||||
|
@ -365,7 +399,7 @@ def set_remote_server(user_id):
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_remote_server(domain):
|
def get_or_create_remote_server(domain):
|
||||||
""" get info on a remote server """
|
"""get info on a remote server"""
|
||||||
try:
|
try:
|
||||||
return FederatedServer.objects.get(server_name=domain)
|
return FederatedServer.objects.get(server_name=domain)
|
||||||
except FederatedServer.DoesNotExist:
|
except FederatedServer.DoesNotExist:
|
||||||
|
@ -394,7 +428,7 @@ def get_or_create_remote_server(domain):
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def get_remote_reviews(outbox):
|
def get_remote_reviews(outbox):
|
||||||
""" ingest reviews by a new remote bookwyrm user """
|
"""ingest reviews by a new remote bookwyrm user"""
|
||||||
outbox_page = outbox + "?page=true&type=Review"
|
outbox_page = outbox + "?page=true&type=Review"
|
||||||
data = get_data(outbox_page)
|
data = get_data(outbox_page)
|
||||||
|
|
||||||
|
|
86
bookwyrm/redis_store.py
Normal file
86
bookwyrm/redis_store.py
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
""" access the activity stores stored in redis """
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from bookwyrm import settings
|
||||||
|
|
||||||
|
r = redis.Redis(
|
||||||
|
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisStore(ABC):
|
||||||
|
"""sets of ranked, related objects, like statuses for a user's feed"""
|
||||||
|
|
||||||
|
max_length = settings.MAX_STREAM_LENGTH
|
||||||
|
|
||||||
|
def get_value(self, obj):
|
||||||
|
"""the object and rank"""
|
||||||
|
return {obj.id: self.get_rank(obj)}
|
||||||
|
|
||||||
|
def add_object_to_related_stores(self, obj, execute=True):
|
||||||
|
"""add an object to all suitable stores"""
|
||||||
|
value = self.get_value(obj)
|
||||||
|
# we want to do this as a bulk operation, hence "pipeline"
|
||||||
|
pipeline = r.pipeline()
|
||||||
|
for store in self.get_stores_for_object(obj):
|
||||||
|
# add the status to the feed
|
||||||
|
pipeline.zadd(store, value)
|
||||||
|
# trim the store
|
||||||
|
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||||
|
if not execute:
|
||||||
|
return pipeline
|
||||||
|
# and go!
|
||||||
|
return pipeline.execute()
|
||||||
|
|
||||||
|
def remove_object_from_related_stores(self, obj):
|
||||||
|
"""remove an object from all stores"""
|
||||||
|
pipeline = r.pipeline()
|
||||||
|
for store in self.get_stores_for_object(obj):
|
||||||
|
pipeline.zrem(store, -1, obj.id)
|
||||||
|
pipeline.execute()
|
||||||
|
|
||||||
|
def bulk_add_objects_to_store(self, objs, store):
|
||||||
|
"""add a list of objects to a given store"""
|
||||||
|
pipeline = r.pipeline()
|
||||||
|
for obj in objs[: self.max_length]:
|
||||||
|
pipeline.zadd(store, self.get_value(obj))
|
||||||
|
if objs:
|
||||||
|
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||||
|
pipeline.execute()
|
||||||
|
|
||||||
|
def bulk_remove_objects_from_store(self, objs, store):
|
||||||
|
"""remoev a list of objects from a given store"""
|
||||||
|
pipeline = r.pipeline()
|
||||||
|
for obj in objs[: self.max_length]:
|
||||||
|
pipeline.zrem(store, -1, obj.id)
|
||||||
|
pipeline.execute()
|
||||||
|
|
||||||
|
def get_store(self, store): # pylint: disable=no-self-use
|
||||||
|
"""load the values in a store"""
|
||||||
|
return r.zrevrange(store, 0, -1)
|
||||||
|
|
||||||
|
def populate_store(self, store):
|
||||||
|
"""go from zero to a store"""
|
||||||
|
pipeline = r.pipeline()
|
||||||
|
queryset = self.get_objects_for_store(store)
|
||||||
|
|
||||||
|
for obj in queryset[: self.max_length]:
|
||||||
|
pipeline.zadd(store, self.get_value(obj))
|
||||||
|
|
||||||
|
# only trim the store if objects were added
|
||||||
|
if queryset.exists():
|
||||||
|
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||||
|
pipeline.execute()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_objects_for_store(self, store):
|
||||||
|
"""a queryset of what should go in a store, used for populating it"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_stores_for_object(self, obj):
|
||||||
|
"""the stores that an object belongs in"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_rank(self, obj):
|
||||||
|
"""how to rank an object"""
|
|
@ -3,7 +3,7 @@ from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
||||||
""" Removes any html that isn't allowed_tagsed from a block """
|
"""Removes any html that isn't allowed_tagsed from a block"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
HTMLParser.__init__(self)
|
HTMLParser.__init__(self)
|
||||||
|
@ -28,7 +28,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
||||||
self.allow_html = True
|
self.allow_html = True
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
def handle_starttag(self, tag, attrs):
|
||||||
""" check if the tag is valid """
|
"""check if the tag is valid"""
|
||||||
if self.allow_html and tag in self.allowed_tags:
|
if self.allow_html and tag in self.allowed_tags:
|
||||||
self.output.append(("tag", self.get_starttag_text()))
|
self.output.append(("tag", self.get_starttag_text()))
|
||||||
self.tag_stack.append(tag)
|
self.tag_stack.append(tag)
|
||||||
|
@ -36,7 +36,7 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
||||||
self.output.append(("data", ""))
|
self.output.append(("data", ""))
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
def handle_endtag(self, tag):
|
||||||
""" keep the close tag """
|
"""keep the close tag"""
|
||||||
if not self.allow_html or tag not in self.allowed_tags:
|
if not self.allow_html or tag not in self.allowed_tags:
|
||||||
self.output.append(("data", ""))
|
self.output.append(("data", ""))
|
||||||
return
|
return
|
||||||
|
@ -51,11 +51,11 @@ class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
|
||||||
self.output.append(("tag", "</%s>" % tag))
|
self.output.append(("tag", "</%s>" % tag))
|
||||||
|
|
||||||
def handle_data(self, data):
|
def handle_data(self, data):
|
||||||
""" extract the answer, if we're in an answer tag """
|
"""extract the answer, if we're in an answer tag"""
|
||||||
self.output.append(("data", data))
|
self.output.append(("data", data))
|
||||||
|
|
||||||
def get_output(self):
|
def get_output(self):
|
||||||
""" convert the output from a list of tuples to a string """
|
"""convert the output from a list of tuples to a string"""
|
||||||
if self.tag_stack:
|
if self.tag_stack:
|
||||||
self.allow_html = False
|
self.allow_html = False
|
||||||
if not self.allow_html:
|
if not self.allow_html:
|
||||||
|
|
|
@ -24,7 +24,8 @@ EMAIL_HOST = env("EMAIL_HOST")
|
||||||
EMAIL_PORT = env("EMAIL_PORT", 587)
|
EMAIL_PORT = env("EMAIL_PORT", 587)
|
||||||
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
||||||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
|
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
|
||||||
EMAIL_USE_TLS = env("EMAIL_USE_TLS", True)
|
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
|
||||||
|
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
|
||||||
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
|
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
|
@ -33,6 +34,8 @@ LOCALE_PATHS = [
|
||||||
os.path.join(BASE_DIR, "locale"),
|
os.path.join(BASE_DIR, "locale"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
|
||||||
|
|
||||||
|
@ -97,6 +100,7 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application"
|
||||||
# redis/activity streams settings
|
# redis/activity streams settings
|
||||||
REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
|
REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
|
||||||
REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
|
REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
|
||||||
|
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
|
||||||
|
|
||||||
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
|
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
|
||||||
STREAMS = ["home", "local", "federated"]
|
STREAMS = ["home", "local", "federated"]
|
||||||
|
@ -151,7 +155,7 @@ LANGUAGES = [
|
||||||
("de-de", _("German")),
|
("de-de", _("German")),
|
||||||
("es", _("Spanish")),
|
("es", _("Spanish")),
|
||||||
("fr-fr", _("French")),
|
("fr-fr", _("French")),
|
||||||
("zh-cn", _("Simplified Chinese")),
|
("zh-hans", _("Simplified Chinese")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -165,7 +169,7 @@ USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/2.0/howto/static-files/
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
|
|
||||||
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
|
@ -13,7 +13,7 @@ MAX_SIGNATURE_AGE = 300
|
||||||
|
|
||||||
|
|
||||||
def create_key_pair():
|
def create_key_pair():
|
||||||
""" a new public/private key pair, used for creating new users """
|
"""a new public/private key pair, used for creating new users"""
|
||||||
random_generator = Random.new().read
|
random_generator = Random.new().read
|
||||||
key = RSA.generate(1024, random_generator)
|
key = RSA.generate(1024, random_generator)
|
||||||
private_key = key.export_key().decode("utf8")
|
private_key = key.export_key().decode("utf8")
|
||||||
|
@ -23,7 +23,7 @@ def create_key_pair():
|
||||||
|
|
||||||
|
|
||||||
def make_signature(sender, destination, date, digest):
|
def make_signature(sender, destination, date, digest):
|
||||||
""" uses a private key to sign an outgoing message """
|
"""uses a private key to sign an outgoing message"""
|
||||||
inbox_parts = urlparse(destination)
|
inbox_parts = urlparse(destination)
|
||||||
signature_headers = [
|
signature_headers = [
|
||||||
"(request-target): post %s" % inbox_parts.path,
|
"(request-target): post %s" % inbox_parts.path,
|
||||||
|
@ -44,14 +44,14 @@ def make_signature(sender, destination, date, digest):
|
||||||
|
|
||||||
|
|
||||||
def make_digest(data):
|
def make_digest(data):
|
||||||
""" creates a message digest for signing """
|
"""creates a message digest for signing"""
|
||||||
return "SHA-256=" + b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode(
|
return "SHA-256=" + b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode(
|
||||||
"utf-8"
|
"utf-8"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def verify_digest(request):
|
def verify_digest(request):
|
||||||
""" checks if a digest is syntactically valid and matches the message """
|
"""checks if a digest is syntactically valid and matches the message"""
|
||||||
algorithm, digest = request.headers["digest"].split("=", 1)
|
algorithm, digest = request.headers["digest"].split("=", 1)
|
||||||
if algorithm == "SHA-256":
|
if algorithm == "SHA-256":
|
||||||
hash_function = hashlib.sha256
|
hash_function = hashlib.sha256
|
||||||
|
@ -66,7 +66,7 @@ def verify_digest(request):
|
||||||
|
|
||||||
|
|
||||||
class Signature:
|
class Signature:
|
||||||
""" read and validate incoming signatures """
|
"""read and validate incoming signatures"""
|
||||||
|
|
||||||
def __init__(self, key_id, headers, signature):
|
def __init__(self, key_id, headers, signature):
|
||||||
self.key_id = key_id
|
self.key_id = key_id
|
||||||
|
@ -75,7 +75,7 @@ class Signature:
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def parse(cls, request):
|
def parse(cls, request):
|
||||||
""" extract and parse a signature from an http request """
|
"""extract and parse a signature from an http request"""
|
||||||
signature_dict = {}
|
signature_dict = {}
|
||||||
for pair in request.headers["Signature"].split(","):
|
for pair in request.headers["Signature"].split(","):
|
||||||
k, v = pair.split("=", 1)
|
k, v = pair.split("=", 1)
|
||||||
|
@ -92,7 +92,7 @@ class Signature:
|
||||||
return cls(key_id, headers, signature)
|
return cls(key_id, headers, signature)
|
||||||
|
|
||||||
def verify(self, public_key, request):
|
def verify(self, public_key, request):
|
||||||
""" verify rsa signature """
|
"""verify rsa signature"""
|
||||||
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
|
if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
|
||||||
raise ValueError("Request too old: %s" % (request.headers["date"],))
|
raise ValueError("Request too old: %s" % (request.headers["date"],))
|
||||||
public_key = RSA.import_key(public_key)
|
public_key = RSA.import_key(public_key)
|
||||||
|
@ -118,7 +118,7 @@ class Signature:
|
||||||
|
|
||||||
|
|
||||||
def http_date_age(datestr):
|
def http_date_age(datestr):
|
||||||
""" age of a signature in seconds """
|
"""age of a signature in seconds"""
|
||||||
parsed = datetime.datetime.strptime(datestr, "%a, %d %b %Y %H:%M:%S GMT")
|
parsed = datetime.datetime.strptime(datestr, "%a, %d %b %Y %H:%M:%S GMT")
|
||||||
delta = datetime.datetime.utcnow() - parsed
|
delta = datetime.datetime.utcnow() - parsed
|
||||||
return delta.total_seconds()
|
return delta.total_seconds()
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: 20%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- --- */
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.image {
|
.image {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
@ -25,17 +29,42 @@ html {
|
||||||
min-width: 75% !important;
|
min-width: 75% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- "disabled" for non-buttons --- */
|
/** Utilities not covered by Bulma
|
||||||
.is-disabled {
|
******************************************************************************/
|
||||||
background-color: #dbdbdb;
|
|
||||||
border-color: #dbdbdb;
|
@media only screen and (max-width: 768px) {
|
||||||
box-shadow: none;
|
.is-sr-only-mobile {
|
||||||
color: #7a7a7a;
|
border: none !important;
|
||||||
opacity: 0.5;
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
cursor: not-allowed;
|
height: 0.01em !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
position: absolute !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
width: 0.01em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-0-mobile {
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- SHELVING --- */
|
.button.is-transparent {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-stretchable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.is-stretchable .card-content {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shelving
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
/** @todo Replace icons with SVG symbols.
|
/** @todo Replace icons with SVG symbols.
|
||||||
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||||
|
@ -45,7 +74,9 @@ html {
|
||||||
margin-left: 0.5em;
|
margin-left: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- TOGGLES --- */
|
/** Toggles
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
.toggle-button[aria-pressed=true],
|
.toggle-button[aria-pressed=true],
|
||||||
.toggle-button[aria-pressed=true]:hover {
|
.toggle-button[aria-pressed=true]:hover {
|
||||||
background-color: hsl(171, 100%, 41%);
|
background-color: hsl(171, 100%, 41%);
|
||||||
|
@ -57,12 +88,8 @@ html {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.transition-x.is-hidden,
|
||||||
display: none !important;
|
.transition-y.is-hidden {
|
||||||
}
|
|
||||||
|
|
||||||
.hidden.transition-y,
|
|
||||||
.hidden.transition-x {
|
|
||||||
display: block !important;
|
display: block !important;
|
||||||
visibility: hidden !important;
|
visibility: hidden !important;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
@ -71,16 +98,18 @@ html {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.transition-x,
|
||||||
.transition-y {
|
.transition-y {
|
||||||
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
|
|
||||||
transition-duration: 0.5s;
|
transition-duration: 0.5s;
|
||||||
transition-timing-function: ease;
|
transition-timing-function: ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transition-x {
|
.transition-x {
|
||||||
transition-property: width, margin-left, margin-right, padding-left, padding-right;
|
transition-property: width, margin-left, margin-right, padding-left, padding-right;
|
||||||
transition-duration: 0.5s;
|
}
|
||||||
transition-timing-function: ease;
|
|
||||||
|
.transition-y {
|
||||||
|
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
@ -90,6 +119,13 @@ html {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stars
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.stars {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/** Stars in a review form
|
/** Stars in a review form
|
||||||
*
|
*
|
||||||
* Specificity makes hovering taking over checked inputs.
|
* Specificity makes hovering taking over checked inputs.
|
||||||
|
@ -121,7 +157,9 @@ html {
|
||||||
content: '\e9d7';
|
content: '\e9d7';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- BOOK COVERS --- */
|
/** Book covers
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
.cover-container {
|
.cover-container {
|
||||||
height: 250px;
|
height: 250px;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
|
@ -186,7 +224,9 @@ html {
|
||||||
padding: 0.1em;
|
padding: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- AVATAR --- */
|
/** Avatars
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
display: inline;
|
display: inline;
|
||||||
|
@ -202,25 +242,107 @@ html {
|
||||||
min-height: 96px;
|
min-height: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- QUOTES --- */
|
/** Statuses: Quotes
|
||||||
.quote blockquote {
|
*
|
||||||
|
* \e906: icon-quote-open
|
||||||
|
* \e905: icon-quote-close
|
||||||
|
*
|
||||||
|
* The `content` class on the blockquote allows to apply styles to markdown
|
||||||
|
* generated HTML in the quote: https://bulma.io/documentation/elements/content/
|
||||||
|
*
|
||||||
|
* ```html
|
||||||
|
* <div class="quote block">
|
||||||
|
* <blockquote dir="auto" class="content mb-2">
|
||||||
|
* User generated quote in markdown…
|
||||||
|
* </blockquote>
|
||||||
|
*
|
||||||
|
* <p> — <a…>Book Title</a> by <a…class="author">Author</a></p>
|
||||||
|
* </div>
|
||||||
|
* ```
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.quote > blockquote {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding-left: 2em;
|
padding-left: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote blockquote::before,
|
.quote > blockquote::before,
|
||||||
.quote blockquote::after {
|
.quote > blockquote::after {
|
||||||
font-family: 'icomoon';
|
font-family: 'icomoon';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote blockquote::before {
|
.quote > blockquote::before {
|
||||||
content: "\e906";
|
content: "\e906";
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quote blockquote::after {
|
.quote > blockquote::after {
|
||||||
content: "\e905";
|
content: "\e905";
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* States
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
/* "disabled" for non-buttons */
|
||||||
|
|
||||||
|
.is-disabled {
|
||||||
|
background-color: #dbdbdb;
|
||||||
|
border-color: #dbdbdb;
|
||||||
|
box-shadow: none;
|
||||||
|
color: #7a7a7a;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Book preview table
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
|
.book-preview td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media only screen and (max-width: 768px) {
|
||||||
|
table.is-mobile,
|
||||||
|
table.is-mobile tbody {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile tr {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-top: 1px solid #dbdbdb;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td {
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex: 1 0 100%;
|
||||||
|
order: 2;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td.book-preview-top-row {
|
||||||
|
order: 1;
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td[data-title]:not(:empty)::before {
|
||||||
|
content: attr(data-title);
|
||||||
|
display: block;
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile td:empty {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.is-mobile th,
|
||||||
|
table.is-mobile thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,10 +1,13 @@
|
||||||
|
|
||||||
|
/** @todo Replace icons with SVG symbols.
|
||||||
|
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'icomoon';
|
font-family: 'icomoon';
|
||||||
src: url('fonts/icomoon.eot?n5x55');
|
src: url('../fonts/icomoon.eot?n5x55');
|
||||||
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
||||||
url('fonts/icomoon.ttf?n5x55') format('truetype'),
|
url('../fonts/icomoon.ttf?n5x55') format('truetype'),
|
||||||
url('fonts/icomoon.woff?n5x55') format('woff'),
|
url('../fonts/icomoon.woff?n5x55') format('woff'),
|
||||||
url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: block;
|
font-display: block;
|
285
bookwyrm/static/js/bookwyrm.js
Normal file
285
bookwyrm/static/js/bookwyrm.js
Normal file
|
@ -0,0 +1,285 @@
|
||||||
|
/* exported BookWyrm */
|
||||||
|
/* globals TabGroup */
|
||||||
|
|
||||||
|
let BookWyrm = new class {
|
||||||
|
constructor() {
|
||||||
|
this.initOnDOMLoaded();
|
||||||
|
this.initReccuringTasks();
|
||||||
|
this.initEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
initEventListeners() {
|
||||||
|
document.querySelectorAll('[data-controls]')
|
||||||
|
.forEach(button => button.addEventListener(
|
||||||
|
'click',
|
||||||
|
this.toggleAction.bind(this))
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelectorAll('.interaction')
|
||||||
|
.forEach(button => button.addEventListener(
|
||||||
|
'submit',
|
||||||
|
this.interact.bind(this))
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelectorAll('.hidden-form input')
|
||||||
|
.forEach(button => button.addEventListener(
|
||||||
|
'change',
|
||||||
|
this.revealForm.bind(this))
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-back]')
|
||||||
|
.forEach(button => button.addEventListener(
|
||||||
|
'click',
|
||||||
|
this.back)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute code once the DOM is loaded.
|
||||||
|
*/
|
||||||
|
initOnDOMLoaded() {
|
||||||
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
|
document.querySelectorAll('.tab-group')
|
||||||
|
.forEach(tabs => new TabGroup(tabs));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute recurring tasks.
|
||||||
|
*/
|
||||||
|
initReccuringTasks() {
|
||||||
|
// Polling
|
||||||
|
document.querySelectorAll('[data-poll]')
|
||||||
|
.forEach(liveArea => this.polling(liveArea));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go back in browser history.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
back(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
history.back();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a counter with recurring requests to the API
|
||||||
|
* The delay is slightly randomized and increased on each cycle.
|
||||||
|
*
|
||||||
|
* @param {Object} counter - DOM node
|
||||||
|
* @param {int} delay - frequency for polling in ms
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
polling(counter, delay) {
|
||||||
|
const bookwyrm = this;
|
||||||
|
|
||||||
|
delay = delay || 10000;
|
||||||
|
delay += (Math.random() * 1000);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
fetch('/api/updates/' + counter.dataset.poll)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => bookwyrm.updateCountElement(counter, data));
|
||||||
|
|
||||||
|
bookwyrm.polling(counter, delay * 1.25);
|
||||||
|
}, delay, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a counter.
|
||||||
|
*
|
||||||
|
* @param {object} counter - DOM node
|
||||||
|
* @param {object} data - json formatted response from a fetch
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
updateCountElement(counter, data) {
|
||||||
|
const currentCount = counter.innerText;
|
||||||
|
const count = data.count;
|
||||||
|
|
||||||
|
if (count != currentCount) {
|
||||||
|
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
|
||||||
|
counter.innerText = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle form.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
revealForm(event) {
|
||||||
|
let trigger = event.currentTarget;
|
||||||
|
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
|
||||||
|
|
||||||
|
this.addRemoveClass(hidden, 'is-hidden', !hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute actions on targets based on triggers.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
toggleAction(event) {
|
||||||
|
let trigger = event.currentTarget;
|
||||||
|
let pressed = trigger.getAttribute('aria-pressed') === 'false';
|
||||||
|
let targetId = trigger.dataset.controls;
|
||||||
|
|
||||||
|
// Toggle pressed status on all triggers controlling the same target.
|
||||||
|
document.querySelectorAll('[data-controls="' + targetId + '"]')
|
||||||
|
.forEach(otherTrigger => otherTrigger.setAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
otherTrigger.getAttribute('aria-pressed') === 'false'
|
||||||
|
));
|
||||||
|
|
||||||
|
// @todo Find a better way to handle the exception.
|
||||||
|
if (targetId && ! trigger.classList.contains('pulldown-menu')) {
|
||||||
|
let target = document.getElementById(targetId);
|
||||||
|
|
||||||
|
this.addRemoveClass(target, 'is-hidden', !pressed);
|
||||||
|
this.addRemoveClass(target, 'is-active', pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide pulldown-menus.
|
||||||
|
if (trigger.classList.contains('pulldown-menu')) {
|
||||||
|
this.toggleMenu(trigger, targetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide container.
|
||||||
|
let container = document.getElementById('hide-' + targetId);
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
this.toggleContainer(container, pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check checkbox, if appropriate.
|
||||||
|
let checkbox = trigger.dataset.controlsCheckbox;
|
||||||
|
|
||||||
|
if (checkbox) {
|
||||||
|
this.toggleCheckbox(checkbox, pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set focus, if appropriate.
|
||||||
|
let focus = trigger.dataset.focusTarget;
|
||||||
|
|
||||||
|
if (focus) {
|
||||||
|
this.toggleFocus(focus);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide menus.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
toggleMenu(trigger, targetId) {
|
||||||
|
let expanded = trigger.getAttribute('aria-expanded') == 'false';
|
||||||
|
|
||||||
|
trigger.setAttribute('aria-expanded', expanded);
|
||||||
|
|
||||||
|
if (targetId) {
|
||||||
|
let target = document.getElementById(targetId);
|
||||||
|
|
||||||
|
this.addRemoveClass(target, 'is-active', expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide generic containers.
|
||||||
|
*
|
||||||
|
* @param {object} container - DOM node
|
||||||
|
* @param {boolean} pressed - Is the trigger pressed?
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
toggleContainer(container, pressed) {
|
||||||
|
this.addRemoveClass(container, 'is-hidden', pressed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check or uncheck a checbox.
|
||||||
|
*
|
||||||
|
* @param {object} checkbox - DOM node
|
||||||
|
* @param {boolean} pressed - Is the trigger pressed?
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
toggleCheckbox(checkbox, pressed) {
|
||||||
|
document.getElementById(checkbox).checked = !!pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give the focus to an element.
|
||||||
|
* Only move the focus based on user interactions.
|
||||||
|
*
|
||||||
|
* @param {string} nodeId - ID of the DOM node to focus (button, link…)
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
toggleFocus(nodeId) {
|
||||||
|
let node = document.getElementById(nodeId);
|
||||||
|
|
||||||
|
node.focus();
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
node.selectionStart = node.selectionEnd = 10000;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a request and update the UI accordingly.
|
||||||
|
* This function is used for boosts, favourites, follows and unfollows.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
interact(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const bookwyrm = this;
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const relatedforms = document.querySelectorAll(`.${form.dataset.id}`);
|
||||||
|
|
||||||
|
// Toggle class on all related forms.
|
||||||
|
relatedforms.forEach(relatedForm => bookwyrm.addRemoveClass(
|
||||||
|
relatedForm,
|
||||||
|
'is-hidden',
|
||||||
|
relatedForm.className.indexOf('is-hidden') == -1
|
||||||
|
));
|
||||||
|
|
||||||
|
this.ajaxPost(form).catch(error => {
|
||||||
|
// @todo Display a notification in the UI instead.
|
||||||
|
console.warn('Request failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a form using POST.
|
||||||
|
*
|
||||||
|
* @param {object} form - Form to be submitted
|
||||||
|
* @return {Promise}
|
||||||
|
*/
|
||||||
|
ajaxPost(form) {
|
||||||
|
return fetch(form.action, {
|
||||||
|
method : "POST",
|
||||||
|
body: new FormData(form)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add or remove a class based on a boolean condition.
|
||||||
|
*
|
||||||
|
* @param {object} node - DOM node to change class on
|
||||||
|
* @param {string} classname - Name of the class
|
||||||
|
* @param {boolean} add - Add?
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
addRemoveClass(node, classname, add) {
|
||||||
|
if (add) {
|
||||||
|
node.classList.add(classname);
|
||||||
|
} else {
|
||||||
|
node.classList.remove(classname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,17 +1,34 @@
|
||||||
/* exported toggleAllCheckboxes */
|
|
||||||
|
|
||||||
/**
|
(function() {
|
||||||
* Toggle all descendant checkboxes of a target.
|
'use strict';
|
||||||
*
|
|
||||||
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
|
/**
|
||||||
*
|
* Toggle all descendant checkboxes of a target.
|
||||||
* @param {Event} event - change Event
|
*
|
||||||
* @return {undefined}
|
* Use `data-target="ID_OF_TARGET"` on the node on which the event is listened
|
||||||
*/
|
* to (checkbox, button, link…), where_ID_OF_TARGET_ should be the ID of an
|
||||||
function toggleAllCheckboxes(event) {
|
* ancestor for the checkboxes.
|
||||||
const mainCheckbox = event.target;
|
*
|
||||||
|
* @example
|
||||||
|
* <input
|
||||||
|
* type="checkbox"
|
||||||
|
* data-action="toggle-all"
|
||||||
|
* data-target="failed-imports"
|
||||||
|
* >
|
||||||
|
* @param {Event} event
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
function toggleAllCheckboxes(event) {
|
||||||
|
const mainCheckbox = event.target;
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
||||||
|
.forEach(checkbox => checkbox.checked = mainCheckbox.checked);
|
||||||
|
}
|
||||||
|
|
||||||
document
|
document
|
||||||
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
.querySelectorAll('[data-action="toggle-all"]')
|
||||||
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
|
.forEach(input => {
|
||||||
}
|
input.addEventListener('change', toggleAllCheckboxes);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
|
@ -1,20 +1,43 @@
|
||||||
/* exported updateDisplay */
|
/* exported LocalStorageTools */
|
||||||
/* globals addRemoveClass */
|
/* globals BookWyrm */
|
||||||
|
|
||||||
// set javascript listeners
|
let LocalStorageTools = new class {
|
||||||
function updateDisplay(e) {
|
constructor() {
|
||||||
// used in set reading goal
|
document.querySelectorAll('[data-hide]')
|
||||||
var key = e.target.getAttribute('data-id');
|
.forEach(t => this.setDisplay(t));
|
||||||
var value = e.target.getAttribute('data-value');
|
|
||||||
window.localStorage.setItem(key, value);
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-hide="' + key + '"]')
|
document.querySelectorAll('.set-display')
|
||||||
.forEach(t => setDisplay(t));
|
.forEach(t => t.addEventListener('click', this.updateDisplay.bind(this)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDisplay(el) {
|
/**
|
||||||
// used in set reading goal
|
* Update localStorage, then display content based on keys in localStorage.
|
||||||
var key = el.getAttribute('data-hide');
|
*
|
||||||
var value = window.localStorage.getItem(key);
|
* @param {Event} event
|
||||||
addRemoveClass(el, 'hidden', value);
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
updateDisplay(event) {
|
||||||
|
// used in set reading goal
|
||||||
|
let key = event.target.dataset.id;
|
||||||
|
let value = event.target.dataset.value;
|
||||||
|
|
||||||
|
window.localStorage.setItem(key, value);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-hide="' + key + '"]')
|
||||||
|
.forEach(node => this.setDisplay(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle display of a DOM node based on its value in the localStorage.
|
||||||
|
*
|
||||||
|
* @param {object} node - DOM node to toggle.
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
setDisplay(node) {
|
||||||
|
// used in set reading goal
|
||||||
|
let key = node.dataset.hide;
|
||||||
|
let value = window.localStorage.getItem(key);
|
||||||
|
|
||||||
|
BookWyrm.addRemoveClass(node, 'is-hidden', value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,169 +0,0 @@
|
||||||
/* globals setDisplay TabGroup toggleAllCheckboxes updateDisplay */
|
|
||||||
|
|
||||||
// set up javascript listeners
|
|
||||||
window.onload = function() {
|
|
||||||
// buttons that display or hide content
|
|
||||||
document.querySelectorAll('[data-controls]')
|
|
||||||
.forEach(t => t.onclick = toggleAction);
|
|
||||||
|
|
||||||
// javascript interactions (boost/fav)
|
|
||||||
Array.from(document.getElementsByClassName('interaction'))
|
|
||||||
.forEach(t => t.onsubmit = interact);
|
|
||||||
|
|
||||||
// handle aria settings on menus
|
|
||||||
Array.from(document.getElementsByClassName('pulldown-menu'))
|
|
||||||
.forEach(t => t.onclick = toggleMenu);
|
|
||||||
|
|
||||||
// hidden submit button in a form
|
|
||||||
document.querySelectorAll('.hidden-form input')
|
|
||||||
.forEach(t => t.onchange = revealForm);
|
|
||||||
|
|
||||||
// polling
|
|
||||||
document.querySelectorAll('[data-poll]')
|
|
||||||
.forEach(el => polling(el));
|
|
||||||
|
|
||||||
// browser back behavior
|
|
||||||
document.querySelectorAll('[data-back]')
|
|
||||||
.forEach(t => t.onclick = back);
|
|
||||||
|
|
||||||
Array.from(document.getElementsByClassName('tab-group'))
|
|
||||||
.forEach(t => new TabGroup(t));
|
|
||||||
|
|
||||||
// display based on localstorage vars
|
|
||||||
document.querySelectorAll('[data-hide]')
|
|
||||||
.forEach(t => setDisplay(t));
|
|
||||||
|
|
||||||
// update localstorage
|
|
||||||
Array.from(document.getElementsByClassName('set-display'))
|
|
||||||
.forEach(t => t.onclick = updateDisplay);
|
|
||||||
|
|
||||||
// Toggle all checkboxes.
|
|
||||||
document
|
|
||||||
.querySelectorAll('[data-action="toggle-all"]')
|
|
||||||
.forEach(input => {
|
|
||||||
input.addEventListener('change', toggleAllCheckboxes);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
function back(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
history.back();
|
|
||||||
}
|
|
||||||
|
|
||||||
function polling(el, delay) {
|
|
||||||
delay = delay || 10000;
|
|
||||||
delay += (Math.random() * 1000);
|
|
||||||
setTimeout(function() {
|
|
||||||
fetch('/api/updates/' + el.getAttribute('data-poll'))
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => updateCountElement(el, data));
|
|
||||||
polling(el, delay * 1.25);
|
|
||||||
}, delay, el);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCountElement(el, data) {
|
|
||||||
const currentCount = el.innerText;
|
|
||||||
const count = data.count;
|
|
||||||
if (count != currentCount) {
|
|
||||||
addRemoveClass(el.closest('[data-poll-wrapper]'), 'hidden', count < 1);
|
|
||||||
el.innerText = count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function revealForm(e) {
|
|
||||||
var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0];
|
|
||||||
if (hidden) {
|
|
||||||
removeClass(hidden, 'hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function toggleAction(e) {
|
|
||||||
var el = e.currentTarget;
|
|
||||||
var pressed = el.getAttribute('aria-pressed') == 'false';
|
|
||||||
|
|
||||||
var targetId = el.getAttribute('data-controls');
|
|
||||||
document.querySelectorAll('[data-controls="' + targetId + '"]')
|
|
||||||
.forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false')));
|
|
||||||
|
|
||||||
if (targetId) {
|
|
||||||
var target = document.getElementById(targetId);
|
|
||||||
addRemoveClass(target, 'hidden', !pressed);
|
|
||||||
addRemoveClass(target, 'is-active', pressed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// show/hide container
|
|
||||||
var container = document.getElementById('hide-' + targetId);
|
|
||||||
if (container) {
|
|
||||||
addRemoveClass(container, 'hidden', pressed);
|
|
||||||
}
|
|
||||||
|
|
||||||
// set checkbox, if appropriate
|
|
||||||
var checkbox = el.getAttribute('data-controls-checkbox');
|
|
||||||
if (checkbox) {
|
|
||||||
document.getElementById(checkbox).checked = !!pressed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// set focus, if appropriate
|
|
||||||
var focus = el.getAttribute('data-focus-target');
|
|
||||||
if (focus) {
|
|
||||||
var focusEl = document.getElementById(focus);
|
|
||||||
focusEl.focus();
|
|
||||||
setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function interact(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
ajaxPost(e.target);
|
|
||||||
var identifier = e.target.getAttribute('data-id');
|
|
||||||
Array.from(document.getElementsByClassName(identifier))
|
|
||||||
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleMenu(e) {
|
|
||||||
var el = e.currentTarget;
|
|
||||||
var expanded = el.getAttribute('aria-expanded') == 'false';
|
|
||||||
el.setAttribute('aria-expanded', expanded);
|
|
||||||
var targetId = el.getAttribute('data-controls');
|
|
||||||
if (targetId) {
|
|
||||||
var target = document.getElementById(targetId);
|
|
||||||
addRemoveClass(target, 'is-active', expanded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ajaxPost(form) {
|
|
||||||
fetch(form.action, {
|
|
||||||
method : "POST",
|
|
||||||
body: new FormData(form)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRemoveClass(el, classname, bool) {
|
|
||||||
if (bool) {
|
|
||||||
addClass(el, classname);
|
|
||||||
} else {
|
|
||||||
removeClass(el, classname);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addClass(el, classname) {
|
|
||||||
var classes = el.className.split(' ');
|
|
||||||
if (classes.indexOf(classname) > -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
el.className = classes.concat(classname).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeClass(el, className) {
|
|
||||||
var classes = [];
|
|
||||||
if (el.className) {
|
|
||||||
classes = el.className.split(' ');
|
|
||||||
}
|
|
||||||
const idx = classes.indexOf(className);
|
|
||||||
if (idx > -1) {
|
|
||||||
classes.splice(idx, 1);
|
|
||||||
}
|
|
||||||
el.className = classes.join(' ');
|
|
||||||
}
|
|
|
@ -6,7 +6,7 @@ from bookwyrm.sanitize_html import InputHtmlParser
|
||||||
|
|
||||||
|
|
||||||
def create_generated_note(user, content, mention_books=None, privacy="public"):
|
def create_generated_note(user, content, mention_books=None, privacy="public"):
|
||||||
""" a note created by the app about user activity """
|
"""a note created by the app about user activity"""
|
||||||
# sanitize input html
|
# sanitize input html
|
||||||
parser = InputHtmlParser()
|
parser = InputHtmlParser()
|
||||||
parser.feed(content)
|
parser.feed(content)
|
||||||
|
|
|
@ -6,24 +6,36 @@
|
||||||
{% block title %}{{ book.title }}{% endblock %}
|
{% block title %}{{ book.title }}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="block">
|
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
|
||||||
|
<div class="block" itemscope itemtype="https://schema.org/Book">
|
||||||
<div class="columns is-mobile">
|
<div class="columns is-mobile">
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<h1 class="title">
|
<h1 class="title">
|
||||||
{{ book.title }}{% if book.subtitle %}:
|
<span itemprop="name">
|
||||||
<small>{{ book.subtitle }}</small>{% endif %}
|
{{ book.title }}{% if book.subtitle %}:
|
||||||
|
<small>{{ book.subtitle }}</small>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
{% if book.series %}
|
{% if book.series %}
|
||||||
<small class="has-text-grey-dark">({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})</small><br>
|
<meta itemprop="isPartOf" content="{{ book.series }}">
|
||||||
|
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
|
||||||
|
|
||||||
|
<small class="has-text-grey-dark">
|
||||||
|
({{ book.series }}
|
||||||
|
{% if book.series_number %} #{{ book.series_number }}{% endif %})
|
||||||
|
</small>
|
||||||
|
<br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
{% if book.authors %}
|
{% if book.authors %}
|
||||||
<h2 class="subtitle">
|
<h2 class="subtitle">
|
||||||
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
|
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
|
||||||
</h2>
|
</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
{% if user_authenticated and can_edit_book %}
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<a href="{{ book.id }}/edit">
|
<a href="{{ book.id }}/edit">
|
||||||
<span class="icon icon-pencil" title="{% trans "Edit Book" %}">
|
<span class="icon icon-pencil" title="{% trans "Edit Book" %}">
|
||||||
|
@ -44,7 +56,7 @@
|
||||||
{% include 'snippets/shelve_button/shelve_button.html' %}
|
{% include 'snippets/shelve_button/shelve_button.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if request.user.is_authenticated and not book.cover %}
|
{% if user_authenticated and not book.cover %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{% trans "Add cover" as button_text %}
|
{% trans "Add cover" as button_text %}
|
||||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %}
|
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %}
|
||||||
|
@ -55,31 +67,16 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="content is-clipped">
|
<section class="is-clipped">
|
||||||
<dl>
|
{% with book=book %}
|
||||||
{% if book.isbn_13 %}
|
<div class="content">
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
{% include 'book/publisher_info.html' %}
|
||||||
<dt>{% trans "ISBN:" %}</dt>
|
|
||||||
<dd>{{ book.isbn_13 }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if book.oclc_number %}
|
<div class="my-3">
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
{% include 'book/book_identifiers.html' %}
|
||||||
<dt>{% trans "OCLC Number:" %}</dt>
|
|
||||||
<dd>{{ book.oclc_number }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endwith %}
|
||||||
|
|
||||||
{% if book.asin %}
|
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
|
||||||
<dt>{% trans "ASIN:" %}</dt>
|
|
||||||
<dd>{{ book.asin }}</dd>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{% include 'book/publisher_info.html' with book=book %}
|
|
||||||
|
|
||||||
{% if book.openlibrary_key %}
|
{% if book.openlibrary_key %}
|
||||||
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
|
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
|
||||||
|
@ -89,18 +86,35 @@
|
||||||
|
|
||||||
<div class="column is-three-fifths">
|
<div class="column is-three-fifths">
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h3 class="field is-grouped">
|
<h3
|
||||||
|
class="field is-grouped"
|
||||||
|
itemprop="aggregateRating"
|
||||||
|
itemscope
|
||||||
|
itemtype="https://schema.org/AggregateRating"
|
||||||
|
>
|
||||||
|
<meta itemprop="ratingValue" content="{{ rating|floatformat }}">
|
||||||
|
{# @todo Is it possible to not hard-code the value? #}
|
||||||
|
<meta itemprop="bestRating" content="5">
|
||||||
|
<meta itemprop="reviewCount" content="{{ review_count }}">
|
||||||
|
|
||||||
{% include 'snippets/stars.html' with rating=rating %}
|
{% include 'snippets/stars.html' with rating=rating %}
|
||||||
{% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %}
|
|
||||||
|
{% blocktrans count counter=review_count trimmed %}
|
||||||
|
({{ review_count }} review)
|
||||||
|
{% plural %}
|
||||||
|
({{ review_count }} reviews)
|
||||||
|
{% endblocktrans %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
|
{% with full=book|book_description itemprop='abstract' %}
|
||||||
|
{% include 'snippets/trimmed_text.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
|
{% if user_authenticated and can_edit_book and not book|book_description %}
|
||||||
{% trans 'Add Description' as button_text %}
|
{% trans 'Add Description' as button_text %}
|
||||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
|
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
|
||||||
|
|
||||||
<div class="box hidden" id="add-description-{{ book.id }}">
|
<div class="box is-hidden" id="add-description-{{ book.id }}">
|
||||||
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
|
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<p class="fields is-grouped">
|
<p class="fields is-grouped">
|
||||||
|
@ -138,7 +152,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if request.user.is_authenticated %}
|
{% if user_authenticated %}
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<header class="columns">
|
<header class="columns">
|
||||||
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
|
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
|
||||||
|
@ -150,7 +164,7 @@
|
||||||
{% if not readthroughs.exists %}
|
{% if not readthroughs.exists %}
|
||||||
<p>{% trans "You don't have any reading activity for this book." %}</p>
|
<p>{% trans "You don't have any reading activity for this book." %}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<section class="hidden box" id="add-readthrough">
|
<section class="is-hidden box" id="add-readthrough">
|
||||||
<form name="add-readthrough" action="/create-readthrough" method="post">
|
<form name="add-readthrough" action="/create-readthrough" method="post">
|
||||||
{% include 'snippets/readthrough_form.html' with readthrough=None %}
|
{% include 'snippets/readthrough_form.html' with readthrough=None %}
|
||||||
<div class="field is-grouped">
|
<div class="field is-grouped">
|
||||||
|
@ -176,14 +190,15 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-one-fifth">
|
<div class="column is-one-fifth">
|
||||||
{% if book.subjects %}
|
{% if book.subjects %}
|
||||||
<section class="content block">
|
<section class="content block">
|
||||||
<h2 class="title is-5">{% trans "Subjects" %}</h2>
|
<h2 class="title is-5">{% trans "Subjects" %}</h2>
|
||||||
<ul>
|
|
||||||
{% for subject in book.subjects %}
|
<ul>
|
||||||
<li>{{ subject }}</li>
|
{% for subject in book.subjects %}
|
||||||
{% endfor %}
|
<li itemprop="about">{{ subject }}</li>
|
||||||
</ul>
|
{% endfor %}
|
||||||
</section>
|
</ul>
|
||||||
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if book.subject_places %}
|
{% if book.subject_places %}
|
||||||
|
@ -229,43 +244,85 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="block" id="reviews">
|
<div class="block" id="reviews">
|
||||||
{% for review in reviews %}
|
{% if request.user.is_authenticated %}
|
||||||
<div class="block">
|
<nav class="tabs">
|
||||||
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
|
<ul>
|
||||||
</div>
|
{% url 'book' book.id as tab_url %}
|
||||||
{% endfor %}
|
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||||
|
<a href="{{ tab_url }}">{% trans "Reviews" %} ({{ review_count }})</a>
|
||||||
|
</li>
|
||||||
|
{% if user_statuses.review_count %}
|
||||||
|
{% url 'book-user-statuses' book.id 'review' as tab_url %}
|
||||||
|
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||||
|
<a href="{{ tab_url }}">{% trans "Your reviews" %} ({{ user_statuses.review_count }})</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if user_statuses.comment_count %}
|
||||||
|
{% url 'book-user-statuses' book.id 'comment' as tab_url %}
|
||||||
|
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||||
|
<a href="{{ tab_url }}">{% trans "Your comments" %} ({{ user_statuses.comment_count }})</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if user_statuses.quotation_count %}
|
||||||
|
{% url 'book-user-statuses' book.id 'quote' as tab_url %}
|
||||||
|
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||||
|
<a href="{{ tab_url }}">{% trans "Your quotes" %} ({{ user_statuses.quotation_count }})</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="block is-flex is-flex-wrap-wrap">
|
{% for review in statuses %}
|
||||||
{% for rating in ratings %}
|
<div
|
||||||
<div class="block mr-5">
|
class="block"
|
||||||
<div class="media">
|
itemprop="review"
|
||||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
itemscope
|
||||||
<div class="media-content">
|
itemtype="https://schema.org/Review"
|
||||||
<div>
|
>
|
||||||
<a href="{{ rating.user.local_path }}">{{ rating.user.display_name }}</a>
|
{% with status=review hide_book=True depth=1 %}
|
||||||
</div>
|
{% include 'snippets/status/status.html' %}
|
||||||
<div class="is-flex">
|
{% endwith %}
|
||||||
<p class="mr-1">{% trans "rated it" %}</p>
|
|
||||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
|
||||||
<div class="block">
|
<div class="block is-flex is-flex-wrap-wrap">
|
||||||
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
|
{% for rating in ratings %}
|
||||||
|
{% with user=rating.user %}
|
||||||
|
<div class="block mr-5">
|
||||||
|
<div class="media">
|
||||||
|
<div class="media-left">
|
||||||
|
{% include 'snippets/avatar.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="media-content">
|
||||||
|
<div>
|
||||||
|
<a href="{{ user.local_path }}">{{ user.display_name }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="is-flex">
|
||||||
|
<p class="mr-1">{% trans "rated it" %}</p>
|
||||||
|
|
||||||
|
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="block">
|
||||||
|
{% include 'snippets/pagination.html' with page=statuses path=request.path anchor="#reviews" %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endwith %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/tabs.js"></script>
|
<script src="/static/js/vendor/tabs.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
27
bookwyrm/templates/book/book_identifiers.html
Normal file
27
bookwyrm/templates/book/book_identifiers.html
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
{% spaceless %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<dl>
|
||||||
|
{% if book.isbn_13 %}
|
||||||
|
<div class="is-flex">
|
||||||
|
<dt class="mr-1">{% trans "ISBN:" %}</dt>
|
||||||
|
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if book.oclc_number %}
|
||||||
|
<div class="is-flex">
|
||||||
|
<dt class="mr-1">{% trans "OCLC Number:" %}</dt>
|
||||||
|
<dd>{{ book.oclc_number }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if book.asin %}
|
||||||
|
<div class="is-flex">
|
||||||
|
<dt class="mr-1">{% trans "ASIN:" %}</dt>
|
||||||
|
<dd>{{ book.asin }}</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
{% endspaceless %}
|
|
@ -88,12 +88,18 @@
|
||||||
<div class="column is-half">
|
<div class="column is-half">
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
||||||
<p class="mb-2"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
|
<p class="mb-2">
|
||||||
|
<label class="label" for="id_title">{% trans "Title:" %}</label>
|
||||||
|
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
|
||||||
|
</p>
|
||||||
{% for error in form.title.errors %}
|
{% for error in form.title.errors %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<p class="mb-2"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p>
|
<p class="mb-2">
|
||||||
|
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
|
||||||
|
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
|
||||||
|
</p>
|
||||||
{% for error in form.subtitle.errors %}
|
{% for error in form.subtitle.errors %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -103,7 +109,10 @@
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<p class="mb-2"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p>
|
<p class="mb-2">
|
||||||
|
<label class="label" for="id_series">{% trans "Series:" %}</label>
|
||||||
|
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
|
||||||
|
</p>
|
||||||
{% for error in form.series.errors %}
|
{% for error in form.series.errors %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -124,7 +133,7 @@
|
||||||
|
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
|
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
|
||||||
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if book.first_published_date %} value="{{ book.first_published_date|date:'Y-m-d' }}"{% endif %}>
|
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %}>
|
||||||
</p>
|
</p>
|
||||||
{% for error in form.first_published_date.errors %}
|
{% for error in form.first_published_date.errors %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
|
@ -132,7 +141,7 @@
|
||||||
|
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
|
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
|
||||||
<input type="date" name="published_date" class="input" id="id_published_date"{% if book.published_date %} value="{{ book.published_date|date:'Y-m-d' }}"{% endif %}>
|
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %}>
|
||||||
</p>
|
</p>
|
||||||
{% for error in form.published_date.errors %}
|
{% for error in form.published_date.errors %}
|
||||||
<p class="help is-danger">{{ error | escape }}</p>
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
|
|
|
@ -25,7 +25,18 @@
|
||||||
{{ book.title }}
|
{{ book.title }}
|
||||||
</a>
|
</a>
|
||||||
</h2>
|
</h2>
|
||||||
{% include 'book/publisher_info.html' with book=book %}
|
|
||||||
|
{% with book=book %}
|
||||||
|
<div class="columns is-multiline">
|
||||||
|
<div class="column is-half">
|
||||||
|
{% include 'book/publisher_info.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column is-half ">
|
||||||
|
{% include 'book/book_identifiers.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-3">
|
<div class="column is-3">
|
||||||
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
|
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
|
||||||
|
|
|
@ -1,24 +1,70 @@
|
||||||
|
{% spaceless %}
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load humanize %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% if book.physical_format and not book.pages %}
|
{% with format=book.physical_format pages=book.pages %}
|
||||||
{{ book.physical_format | title }}
|
{% if format %}
|
||||||
{% elif book.physical_format and book.pages %}
|
{% comment %}
|
||||||
{% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
@todo The bookFormat property is limited to a list of values whereas the book edition is free text.
|
||||||
{% elif book.pages %}
|
@see https://schema.org/bookFormat
|
||||||
{% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %}
|
{% endcomment %}
|
||||||
{% endif %}
|
<meta itemprop="bookFormat" content="{{ format }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if pages %}
|
||||||
|
<meta itemprop="numberOfPages" content="{{ pages }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if format and not pages %}
|
||||||
|
{% blocktrans %}{{ format }}{% endblocktrans %}
|
||||||
|
{% elif format and pages %}
|
||||||
|
{% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
||||||
|
{% elif pages %}
|
||||||
|
{% blocktrans %}{{ pages }} pages{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if book.languages %}
|
{% if book.languages %}
|
||||||
<p>
|
{% for language in book.languages %}
|
||||||
{% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %}
|
<meta itemprop="inLanguage" content="{{ language }}">
|
||||||
</p>
|
{% endfor %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{% with languages=book.languages|join:", " %}
|
||||||
|
{% blocktrans %}{{ languages }} language{% endblocktrans %}
|
||||||
|
{% endwith %}
|
||||||
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% if book.published_date and book.publishers %}
|
{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
|
||||||
{% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
{% if date or book.first_published_date %}
|
||||||
{% elif book.published_date %}
|
<meta
|
||||||
{% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %}
|
itemprop="datePublished"
|
||||||
{% elif book.publishers %}
|
content="{{ book.first_published_date|default:book.published_date|date:'Y-m-d' }}"
|
||||||
{% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %}
|
>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
@todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
|
||||||
|
@see https://schema.org/Publisher
|
||||||
|
{% endcomment %}
|
||||||
|
{% if book.publishers %}
|
||||||
|
{% for publisher in book.publishers %}
|
||||||
|
<meta itemprop="publisher" content="{{ publisher }}">
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if date and publisher %}
|
||||||
|
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
||||||
|
{% elif date %}
|
||||||
|
{% blocktrans %}Published {{ date }}{% endblocktrans %}
|
||||||
|
{% elif publisher %}
|
||||||
|
{% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</p>
|
</p>
|
||||||
|
{% endspaceless %}
|
||||||
|
|
|
@ -1,13 +1,34 @@
|
||||||
|
{% spaceless %}
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
|
|
||||||
{% with 0|uuid as uuid %}
|
{% with 0|uuid as uuid %}
|
||||||
<div class="dropdown control{% if right %} is-right{% endif %}" id="menu-{{ uuid }}">
|
<div
|
||||||
<button type="button" class="button dropdown-trigger pulldown-menu {{ class }}" aria-expanded="false" class="pulldown-menu" aria-haspopup="true" aria-controls="menu-options-{{ uuid }}" data-controls="menu-{{ uuid }}">
|
id="menu-{{ uuid }}"
|
||||||
|
class="
|
||||||
|
dropdown control
|
||||||
|
{% if right %}is-right{% endif %}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="button dropdown-trigger pulldown-menu {{ class }}"
|
||||||
|
type="button"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-controls="menu-options-{{ uuid }}"
|
||||||
|
data-controls="menu-{{ uuid }}"
|
||||||
|
>
|
||||||
{% block dropdown-trigger %}{% endblock %}
|
{% block dropdown-trigger %}{% endblock %}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ uuid }}">
|
<ul
|
||||||
|
id="menu-options-{{ uuid }}"
|
||||||
|
class="dropdown-content p-0 is-clipped"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
{% block dropdown-list %}{% endblock %}
|
{% block dropdown-list %}{% endblock %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
{% endspaceless %}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<section class="card hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
<section class="card is-hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||||
<header class="card-header has-background-white-ter">
|
<header class="card-header has-background-white-ter">
|
||||||
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
|
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
|
||||||
{% block header %}{% endblock %}
|
{% block header %}{% endblock %}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
class="modal hidden"
|
class="modal is-hidden"
|
||||||
id="{{ controls_text }}-{{ controls_uid }}"
|
id="{{ controls_text }}-{{ controls_uid }}"
|
||||||
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
|
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
<div class="columns is-multiline">
|
<div class="columns is-multiline">
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
<div class="column is-one-third">
|
<div class="column is-one-third">
|
||||||
<div class="card block">
|
<div class="card is-stretchable">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<a href="{{ user.local_path }}" class="media-left">
|
<a href="{{ user.local_path }}" class="media-left">
|
||||||
|
@ -56,13 +56,13 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div>
|
||||||
{% if user.summary %}
|
{% if user.summary %}
|
||||||
{{ user.summary | to_markdown | safe | truncatechars_html:40 }}
|
{{ user.summary | to_markdown | safe | truncatechars_html:40 }}
|
||||||
{% else %} {% endif %}
|
{% else %} {% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<footer class="card-footer content">
|
<footer class="card-footer">
|
||||||
{% if user != request.user %}
|
{% if user != request.user %}
|
||||||
{% if user.mutuals %}
|
{% if user.mutuals %}
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
|
|
|
@ -29,7 +29,7 @@
|
||||||
|
|
||||||
{# announcements and system messages #}
|
{# announcements and system messages #}
|
||||||
{% if not activities.number > 1 %}
|
{% if not activities.number > 1 %}
|
||||||
<a href="{{ request.path }}" class="transition-y hidden notification is-primary is-block" data-poll-wrapper>
|
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
|
||||||
{% blocktrans %}load <span data-poll="stream/{{ tab }}">0</span> unread status(es){% endblocktrans %}
|
{% blocktrans %}load <span data-poll="stream/{{ tab }}">0</span> unread status(es){% endblocktrans %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
@ -104,5 +104,5 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="/static/js/tabs.js"></script>
|
<script src="/static/js/vendor/tabs.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
{% if user == request.user %}
|
{% if user == request.user %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{% now 'Y' as year %}
|
{% now 'Y' as year %}
|
||||||
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
|
<section class="card {% if goal %}is-hidden{% endif %}" id="show-edit-goal">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
|
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
|
||||||
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {% blocktrans %}{{ year }} Reading Goal{% endblocktrans %}
|
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {% blocktrans %}{{ year }} Reading Goal{% endblocktrans %}
|
||||||
|
|
|
@ -26,7 +26,7 @@
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="id_csv_field">{% trans "Data file:" %}</label>
|
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
|
||||||
{{ import_form.csv_file }}
|
{{ import_form.csv_file }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="{% get_lang %}">
|
||||||
<head>
|
<head>
|
||||||
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
|
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link type="text/css" rel="stylesheet" href="/static/css/bulma.min.css">
|
<link rel="stylesheet" href="/static/css/vendor/bulma.min.css">
|
||||||
<link type="text/css" rel="stylesheet" href="/static/css/format.css">
|
<link rel="stylesheet" href="/static/css/vendor/icons.css">
|
||||||
<link type="text/css" rel="stylesheet" href="/static/css/icons.css">
|
<link rel="stylesheet" href="/static/css/bookwyrm.css">
|
||||||
|
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}/images/{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
|
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}/images/{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
|
||||||
|
|
||||||
|
@ -22,165 +22,169 @@
|
||||||
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar container" aria-label="main navigation">
|
<nav class="navbar" aria-label="main navigation">
|
||||||
<div class="navbar-brand">
|
<div class="container">
|
||||||
<a class="navbar-item" href="/">
|
<div class="navbar-brand">
|
||||||
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
|
<a class="navbar-item" href="/">
|
||||||
</a>
|
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
|
||||||
<form class="navbar-item column" action="/search/">
|
|
||||||
<div class="field has-addons">
|
|
||||||
<div class="control">
|
|
||||||
<input aria-label="{% trans 'Search for a book or user' %}" id="search-input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
|
|
||||||
</div>
|
|
||||||
<div class="control">
|
|
||||||
<button class="button" type="submit">
|
|
||||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
|
||||||
<span class="is-sr-only">{% trans "Search" %}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main-nav" aria-expanded="false">
|
|
||||||
<div class="navbar-item mt-3">
|
|
||||||
<div class="icon icon-dots-three-vertical" title="{% trans 'Main navigation menu' %}">
|
|
||||||
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="navbar-menu" id="main-nav">
|
|
||||||
<div class="navbar-start">
|
|
||||||
{% if request.user.is_authenticated %}
|
|
||||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
|
||||||
{% trans "Your books" %}
|
|
||||||
</a>
|
</a>
|
||||||
<a href="/#feed" class="navbar-item">
|
<form class="navbar-item column" action="/search/">
|
||||||
{% trans "Feed" %}
|
<div class="field has-addons">
|
||||||
</a>
|
<div class="control">
|
||||||
<a href="{% url 'lists' %}" class="navbar-item">
|
<input aria-label="{% trans 'Search for a book or user' %}" id="search-input" class="input" type="text" name="q" placeholder="{% trans 'Search for a book or user' %}" value="{{ query }}">
|
||||||
{% trans "Lists" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="navbar-end">
|
|
||||||
{% if request.user.is_authenticated %}
|
|
||||||
<div class="navbar-item has-dropdown is-hoverable">
|
|
||||||
<a
|
|
||||||
href="{{ user.local_path }}"
|
|
||||||
class="navbar-link pulldown-menu"
|
|
||||||
role="button"
|
|
||||||
aria-expanded="false"
|
|
||||||
tabindex="0"
|
|
||||||
aria-haspopup="true"
|
|
||||||
aria-controls="navbar-dropdown"
|
|
||||||
>
|
|
||||||
{% include 'snippets/avatar.html' with user=request.user %}
|
|
||||||
<span class="ml-2">{{ request.user.display_name }}</span>
|
|
||||||
</a>
|
|
||||||
<ul class="navbar-dropdown" id="navbar-dropdown">
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'direct-messages' %}" class="navbar-item">
|
|
||||||
{% trans "Direct Messages" %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'directory' %}" class="navbar-item">
|
|
||||||
{% trans 'Directory' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/import" class="navbar-item">
|
|
||||||
{% trans 'Import Books' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/preferences/profile" class="navbar-item">
|
|
||||||
{% trans 'Settings' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
|
|
||||||
<li class="navbar-divider" role="presentation"></li>
|
|
||||||
{% endif %}
|
|
||||||
{% if perms.bookwyrm.create_invites %}
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
|
|
||||||
{% trans 'Invites' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if perms.bookwyrm.moderate_users %}
|
|
||||||
<li>
|
|
||||||
<a href="{% url 'settings-users' %}" class="navbar-item">
|
|
||||||
{% trans 'Admin' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
<li class="navbar-divider" role="presentation"></li>
|
|
||||||
<li>
|
|
||||||
<a href="/logout" class="navbar-item">
|
|
||||||
{% trans 'Log out' %}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="navbar-item">
|
|
||||||
<a href="/notifications" class="tags has-addons">
|
|
||||||
<span class="tag is-medium">
|
|
||||||
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
|
|
||||||
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
|
|
||||||
<span data-poll="notifications">{{ request.user | notification_count }}</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="navbar-item">
|
|
||||||
{% if request.path != '/login' and request.path != '/login/' %}
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column">
|
|
||||||
<form name="login" method="post" action="/login">
|
|
||||||
{% csrf_token %}
|
|
||||||
<div class="columns is-variable is-1">
|
|
||||||
<div class="column">
|
|
||||||
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
|
|
||||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
|
|
||||||
</div>
|
|
||||||
<div class="column">
|
|
||||||
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
|
|
||||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
|
|
||||||
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
|
|
||||||
</div>
|
|
||||||
<div class="column is-narrow">
|
|
||||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
{% if site.allow_registration and request.path != '' and request.path != '/' %}
|
<div class="control">
|
||||||
<div class="column is-narrow">
|
<button class="button" type="submit">
|
||||||
<a href="/" class="button is-link">
|
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||||
{% trans "Join" %}
|
<span class="is-sr-only">{% trans "Search" %}</span>
|
||||||
</a>
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div role="button" tabindex="0" class="navbar-burger pulldown-menu" data-controls="main-nav" aria-expanded="false">
|
||||||
|
<div class="navbar-item mt-3">
|
||||||
|
<div class="icon icon-dots-three-vertical" title="{% trans 'Main navigation menu' %}">
|
||||||
|
<span class="is-sr-only">{% trans "Main navigation menu" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-menu" id="main-nav">
|
||||||
|
<div class="navbar-start">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
||||||
|
{% trans "Your books" %}
|
||||||
|
</a>
|
||||||
|
<a href="/#feed" class="navbar-item">
|
||||||
|
{% trans "Feed" %}
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'lists' %}" class="navbar-item">
|
||||||
|
{% trans "Lists" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="navbar-end">
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<div class="navbar-item has-dropdown is-hoverable">
|
||||||
|
<a
|
||||||
|
href="{{ request.user.local_path }}"
|
||||||
|
class="navbar-link pulldown-menu"
|
||||||
|
role="button"
|
||||||
|
aria-expanded="false"
|
||||||
|
tabindex="0"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-controls="navbar-dropdown"
|
||||||
|
>
|
||||||
|
{% include 'snippets/avatar.html' with user=request.user %}
|
||||||
|
<span class="ml-2">{{ request.user.display_name }}</span>
|
||||||
|
</a>
|
||||||
|
<ul class="navbar-dropdown" id="navbar-dropdown">
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'direct-messages' %}" class="navbar-item">
|
||||||
|
{% trans "Direct Messages" %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'directory' %}" class="navbar-item">
|
||||||
|
{% trans 'Directory' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/import" class="navbar-item">
|
||||||
|
{% trans 'Import Books' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/preferences/profile" class="navbar-item">
|
||||||
|
{% trans 'Settings' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% if perms.bookwyrm.create_invites or perms.moderate_users %}
|
||||||
|
<li class="navbar-divider" role="presentation"></li>
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.bookwyrm.create_invites %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
|
||||||
|
{% trans 'Invites' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.bookwyrm.moderate_users %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'settings-users' %}" class="navbar-item">
|
||||||
|
{% trans 'Admin' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
<li class="navbar-divider" role="presentation"></li>
|
||||||
|
<li>
|
||||||
|
<a href="/logout" class="navbar-item">
|
||||||
|
{% trans 'Log out' %}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-item">
|
||||||
|
<a href="/notifications" class="tags has-addons">
|
||||||
|
<span class="tag is-medium">
|
||||||
|
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
|
||||||
|
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="{% if not request.user|notification_count %}is-hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
|
||||||
|
<span data-poll="notifications">{{ request.user | notification_count }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="navbar-item">
|
||||||
|
{% if request.path != '/login' and request.path != '/login/' %}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column">
|
||||||
|
<form name="login" method="post" action="/login">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="columns is-variable is-1">
|
||||||
|
<div class="column">
|
||||||
|
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
|
||||||
|
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
|
||||||
|
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
|
||||||
|
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if site.allow_registration and request.path != '' and request.path != '/' %}
|
||||||
|
<div class="column is-narrow">
|
||||||
|
<a href="/" class="button is-link">
|
||||||
|
{% trans "Join" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
||||||
<div class="section container">
|
<div class="section is-flex-grow-1">
|
||||||
{% block content %}
|
<div class="container">
|
||||||
{% endblock %}
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
|
@ -212,7 +216,7 @@
|
||||||
<script>
|
<script>
|
||||||
var csrf_token = '{{ csrf_token }}';
|
var csrf_token = '{{ csrf_token }}';
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/shared.js"></script>
|
<script src="/static/js/bookwyrm.js"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -13,10 +13,20 @@
|
||||||
|
|
||||||
<div class="columns mt-3">
|
<div class="columns mt-3">
|
||||||
<section class="column is-three-quarters">
|
<section class="column is-three-quarters">
|
||||||
{% if not items.exists %}
|
{% if request.GET.updated %}
|
||||||
|
<div class="notification is-primary">
|
||||||
|
{% if list.curation != "open" and request.user != list.user %}
|
||||||
|
{% trans "You successfully suggested a book for this list!" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "You successfully added a book to this list!" %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not items.object_list.exists %}
|
||||||
<p>{% trans "This list is currently empty" %}</p>
|
<p>{% trans "This list is currently empty" %}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<ol>
|
<ol start="{{ items.start_index }}">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<li class="block pb-3">
|
<li class="block pb-3">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
@ -30,11 +40,27 @@
|
||||||
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
|
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer has-background-white-bis">
|
<div class="card-footer has-background-white-bis is-align-items-baseline">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
|
<div>
|
||||||
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
|
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
|
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
|
||||||
|
<div class="card-footer-item">
|
||||||
|
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
|
||||||
|
<div class="field has-addons mb-0">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="control">
|
||||||
|
<input id="input-list-position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
|
||||||
|
</div>
|
||||||
|
<div class="control">
|
||||||
|
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label for="input-list-position" class="help">{% trans "List position" %}</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
|
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="item" value="{{ item.id }}">
|
<input type="hidden" name="item" value="{{ item.id }}">
|
||||||
|
@ -47,10 +73,27 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ol>
|
</ol>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% include "snippets/pagination.html" with page=items %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
|
|
||||||
<section class="column is-one-quarter content">
|
<section class="column is-one-quarter content">
|
||||||
|
<h2>{% trans "Sort List" %}</h2>
|
||||||
|
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
|
||||||
|
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
{{ sort_form.sort_by }}
|
||||||
|
</div>
|
||||||
|
<label class="label" for="id_direction">{% trans "Direction" %}</label>
|
||||||
|
<div class="select is-fullwidth">
|
||||||
|
{{ sort_form.direction }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="button is-primary is-fullwidth" type="submit">
|
||||||
|
{% trans "Sort List" %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
|
||||||
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
|
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
|
||||||
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
|
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
|
||||||
<div class="field has-addons">
|
<div class="field has-addons">
|
||||||
|
@ -83,7 +126,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="column">
|
<div class="column">
|
||||||
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
||||||
<form name="add-book" method="post" action="{% url 'list-add-book' %}">
|
<form name="add-book" method="post" action="{% url 'list-add-book' %}{% if query %}?q={{ query }}{% endif %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="book" value="{{ book.id }}">
|
<input type="hidden" name="book" value="{{ book.id }}">
|
||||||
<input type="hidden" name="list" value="{{ list.id }}">
|
<input type="hidden" name="list" value="{{ list.id }}">
|
||||||
|
@ -93,7 +136,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -15,10 +15,12 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
{% trans "Create List" as button_text %}
|
{% trans "Create List" as button_text %}
|
||||||
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text=button_text focus="create-list-header" %}
|
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text=button_text focus="create-list-header" %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{% extends 'settings/admin_layout.html' %}
|
{% extends 'settings/admin_layout.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load bookwyrm_tags %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
|
||||||
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
|
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
|
||||||
|
@ -14,23 +15,9 @@
|
||||||
{% include 'moderation/report_preview.html' with report=report %}
|
{% include 'moderation/report_preview.html' with report=report %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="block content">
|
{% include 'user_admin/user_info.html' with user=report.user %}
|
||||||
<h3>{% trans "Actions" %}</h3>
|
|
||||||
<p><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p>
|
{% include 'user_admin/user_moderation_actions.html' with user=report.user %}
|
||||||
<div class="is-flex">
|
|
||||||
<p class="mr-1">
|
|
||||||
<a class="button" href="{% url 'direct-messages-user' report.user.username %}">{% trans "Send direct message" %}</a>
|
|
||||||
</p>
|
|
||||||
<form name="deactivate" method="post" action="{% url 'settings-report-deactivate' report.id %}">
|
|
||||||
{% csrf_token %}
|
|
||||||
{% if report.user.is_active %}
|
|
||||||
<button type="submit" class="button is-danger is-light">{% trans "Deactivate user" %}</button>
|
|
||||||
{% else %}
|
|
||||||
<button class="button">{% trans "Reactivate user" %}</button>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
|
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
|
||||||
|
@ -64,7 +51,7 @@
|
||||||
{% for status in report.statuses.select_subclasses.all %}
|
{% for status in report.statuses.select_subclasses.all %}
|
||||||
<li>
|
<li>
|
||||||
{% if status.deleted %}
|
{% if status.deleted %}
|
||||||
<em>{% trans "Statuses has been deleted" %}</em>
|
<em>{% trans "Status has been deleted" %}</em>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% include 'snippets/status/status.html' with status=status moderation_mode=True %}
|
{% include 'snippets/status/status.html' with status=status moderation_mode=True %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -15,7 +15,9 @@
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="reporter" value="{{ reporter.id }}">
|
<input type="hidden" name="reporter" value="{{ reporter.id }}">
|
||||||
<input type="hidden" name="user" value="{{ user.id }}">
|
<input type="hidden" name="user" value="{{ user.id }}">
|
||||||
|
{% if status %}
|
||||||
<input type="hidden" name="statuses" value="{{ status.id }}">
|
<input type="hidden" name="statuses" value="{{ status.id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="content">
|
<section class="content">
|
||||||
<p>{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}</p>
|
<p>{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}</p>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue