mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-04 23:36:32 +00:00
Merge branch 'main' into production
This commit is contained in:
commit
d54286b571
101 changed files with 2519 additions and 1433 deletions
50
.env.dev.example
Normal file
50
.env.dev.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: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_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
|
|
@ -44,3 +44,7 @@ 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
|
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",
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
12
.github/workflows/lint-frontend.yaml
vendored
12
.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:
|
||||||
|
@ -22,8 +24,10 @@ jobs:
|
||||||
- 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/contributing.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`
|
See the [installation instructions](https://docs.joinbookwyrm.com/developer-environment.html) on how to set up Bookwyrm in developer environment or production.
|
||||||
- `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.
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ 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
|
||||||
|
|
|
@ -111,7 +111,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())
|
||||||
|
|
|
@ -50,3 +50,30 @@ class OrderedCollectionPage(ActivityObject):
|
||||||
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"
|
||||||
|
|
|
@ -4,7 +4,7 @@ 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)
|
||||||
|
@ -141,37 +141,27 @@ class Reject(Verb):
|
||||||
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"][
|
|
||||||
0
|
|
||||||
].related_model
|
|
||||||
obj = self.to_model(model=model, save=False, allow_create=False)
|
|
||||||
obj.delete()
|
obj.delete()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -219,6 +219,12 @@ 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,
|
||||||
|
|
|
@ -281,3 +281,9 @@ 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"]
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -107,6 +107,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()
|
||||||
|
|
||||||
|
@ -118,4 +128,5 @@ class Command(BaseCommand):
|
||||||
init_groups()
|
init_groups()
|
||||||
init_permissions()
|
init_permissions()
|
||||||
init_connectors()
|
init_connectors()
|
||||||
|
init_federated_servers()
|
||||||
init_settings()
|
init_settings()
|
||||||
|
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -64,6 +64,7 @@ class ActivitypubMixin:
|
||||||
)
|
)
|
||||||
if hasattr(self, "property_fields"):
|
if hasattr(self, "property_fields"):
|
||||||
self.activity_fields += [
|
self.activity_fields += [
|
||||||
|
# pylint: disable=cell-var-from-loop
|
||||||
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
|
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
|
||||||
for f in self.property_fields
|
for f in self.property_fields
|
||||||
]
|
]
|
||||||
|
@ -152,7 +153,7 @@ class ActivitypubMixin:
|
||||||
# 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 = user_model.viewer_aware_objects(user).filter(
|
||||||
local=False,
|
local=False,
|
||||||
)
|
)
|
||||||
# filter users first by whether they're using the desired software
|
# filter users first by whether they're using the desired software
|
||||||
|
@ -356,49 +357,59 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||||
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
|
|
||||||
|
@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)
|
||||||
|
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.get_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.get_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()
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,36 @@ class BookWyrmModel(models.Model):
|
||||||
""" 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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -59,11 +59,9 @@ 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"
|
||||||
)
|
)
|
||||||
|
@ -72,8 +70,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||||
order = fields.IntegerField(blank=True, null=True)
|
order = fields.IntegerField(blank=True, null=True)
|
||||||
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):
|
||||||
|
|
|
@ -66,17 +66,14 @@ 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):
|
||||||
|
|
|
@ -24,6 +24,16 @@ 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 """
|
||||||
|
|
||||||
|
@ -111,6 +121,9 @@ 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_fields = [("following_link", "following")]
|
||||||
|
@ -138,7 +151,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
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
|
||||||
|
|
||||||
|
|
|
@ -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, ...)
|
||||||
|
@ -97,7 +98,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")
|
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"]
|
||||||
|
@ -166,7 +167,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/"
|
||||||
|
|
|
@ -3,7 +3,6 @@ html {
|
||||||
scroll-padding-top: 20%;
|
scroll-padding-top: 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- --- */
|
|
||||||
.image {
|
.image {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
@ -25,17 +24,8 @@ html {
|
||||||
min-width: 75% !important;
|
min-width: 75% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- "disabled" for non-buttons --- */
|
/** Shelving
|
||||||
.is-disabled {
|
******************************************************************************/
|
||||||
background-color: #dbdbdb;
|
|
||||||
border-color: #dbdbdb;
|
|
||||||
box-shadow: none;
|
|
||||||
color: #7a7a7a;
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- 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 +35,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 +49,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 +59,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) {
|
||||||
|
@ -121,7 +111,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 +178,9 @@ html {
|
||||||
padding: 0.1em;
|
padding: 0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- AVATAR --- */
|
/** Avatars
|
||||||
|
******************************************************************************/
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
display: inline;
|
display: inline;
|
||||||
|
@ -202,25 +196,57 @@ 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;
|
||||||
|
}
|
|
@ -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() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
* Toggle all descendant checkboxes of a target.
|
* Toggle all descendant checkboxes of a target.
|
||||||
*
|
*
|
||||||
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
|
* 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
|
||||||
|
* ancestor for the checkboxes.
|
||||||
*
|
*
|
||||||
* @param {Event} event - change Event
|
* @example
|
||||||
|
* <input
|
||||||
|
* type="checkbox"
|
||||||
|
* data-action="toggle-all"
|
||||||
|
* data-target="failed-imports"
|
||||||
|
* >
|
||||||
|
* @param {Event} event
|
||||||
* @return {undefined}
|
* @return {undefined}
|
||||||
*/
|
*/
|
||||||
function toggleAllCheckboxes(event) {
|
function toggleAllCheckboxes(event) {
|
||||||
const mainCheckbox = event.target;
|
const mainCheckbox = event.target;
|
||||||
|
|
||||||
document
|
document
|
||||||
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
||||||
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
|
.forEach(checkbox => checkbox.checked = mainCheckbox.checked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelectorAll('[data-action="toggle-all"]')
|
||||||
|
.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() {
|
||||||
|
document.querySelectorAll('[data-hide]')
|
||||||
|
.forEach(t => this.setDisplay(t));
|
||||||
|
|
||||||
|
document.querySelectorAll('.set-display')
|
||||||
|
.forEach(t => t.addEventListener('click', this.updateDisplay.bind(this)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update localStorage, then display content based on keys in localStorage.
|
||||||
|
*
|
||||||
|
* @param {Event} event
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
updateDisplay(event) {
|
||||||
// used in set reading goal
|
// used in set reading goal
|
||||||
var key = e.target.getAttribute('data-id');
|
let key = event.target.dataset.id;
|
||||||
var value = e.target.getAttribute('data-value');
|
let value = event.target.dataset.value;
|
||||||
|
|
||||||
window.localStorage.setItem(key, value);
|
window.localStorage.setItem(key, value);
|
||||||
|
|
||||||
document.querySelectorAll('[data-hide="' + key + '"]')
|
document.querySelectorAll('[data-hide="' + key + '"]')
|
||||||
.forEach(t => setDisplay(t));
|
.forEach(node => this.setDisplay(node));
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDisplay(el) {
|
/**
|
||||||
|
* 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
|
// used in set reading goal
|
||||||
var key = el.getAttribute('data-hide');
|
let key = node.dataset.hide;
|
||||||
var value = window.localStorage.getItem(key);
|
let value = window.localStorage.getItem(key);
|
||||||
addRemoveClass(el, 'hidden', value);
|
|
||||||
|
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,14 +6,26 @@
|
||||||
{% 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">
|
||||||
|
<span itemprop="name">
|
||||||
{{ book.title }}{% if book.subtitle %}:
|
{{ book.title }}{% if book.subtitle %}:
|
||||||
<small>{{ book.subtitle }}</small>{% endif %}
|
<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 %}
|
||||||
|
@ -23,7 +35,7 @@
|
||||||
{% 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" %}
|
||||||
|
@ -60,7 +72,7 @@
|
||||||
{% if book.isbn_13 %}
|
{% if book.isbn_13 %}
|
||||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||||
<dt>{% trans "ISBN:" %}</dt>
|
<dt>{% trans "ISBN:" %}</dt>
|
||||||
<dd>{{ book.isbn_13 }}</dd>
|
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
@ -89,18 +101,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 +167,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 +179,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">
|
||||||
|
@ -178,9 +207,10 @@
|
||||||
{% 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>
|
<ul>
|
||||||
{% for subject in book.subjects %}
|
{% for subject in book.subjects %}
|
||||||
<li>{{ subject }}</li>
|
<li itemprop="about">{{ subject }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
@ -229,26 +259,37 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="block" id="reviews">
|
<div class="block" id="reviews">
|
||||||
{% for review in reviews %}
|
{% for review in reviews %}
|
||||||
<div class="block">
|
<div
|
||||||
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
|
class="block"
|
||||||
|
itemprop="review"
|
||||||
|
itemscope
|
||||||
|
itemtype="https://schema.org/Review"
|
||||||
|
>
|
||||||
|
{% with status=review hide_book=True depth=1 %}
|
||||||
|
{% include 'snippets/status/status.html' %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="block is-flex is-flex-wrap-wrap">
|
<div class="block is-flex is-flex-wrap-wrap">
|
||||||
{% for rating in ratings %}
|
{% for rating in ratings %}
|
||||||
|
{% with user=rating.user %}
|
||||||
<div class="block mr-5">
|
<div class="block mr-5">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
<div class="media-left">
|
||||||
|
{% include 'snippets/avatar.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ rating.user.local_path }}">{{ rating.user.display_name }}</a>
|
<a href="{{ user.local_path }}">{{ user.display_name }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="is-flex">
|
<div class="is-flex">
|
||||||
<p class="mr-1">{% trans "rated it" %}</p>
|
<p class="mr-1">{% trans "rated it" %}</p>
|
||||||
|
|
||||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
@ -257,15 +298,17 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="block">
|
<div class="block">
|
||||||
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
|
{% include 'snippets/pagination.html' with page=reviews path=book.local_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 %}
|
||||||
|
|
|
@ -1,24 +1,69 @@
|
||||||
|
{% spaceless %}
|
||||||
|
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<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 %}
|
||||||
|
<meta itemprop="bookFormat" content="{{ format }}">
|
||||||
{% endif %}
|
{% 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|date:'M jS Y' 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"
|
||||||
|
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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -5,9 +5,9 @@
|
||||||
<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 %}">
|
||||||
|
|
||||||
|
@ -134,7 +134,7 @@
|
||||||
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
|
<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 data-poll="notifications">{{ request.user | notification_count }}</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -212,7 +212,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>
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="{% if local_results.results %}hidden{% endif %}" id="more-results">
|
<div class="{% if local_results.results %}is-hidden{% endif %}" id="more-results">
|
||||||
{% for result_set in book_results|slice:"1:" %}
|
{% for result_set in book_results|slice:"1:" %}
|
||||||
{% if result_set.results %}
|
{% if result_set.results %}
|
||||||
<section class="block">
|
<section class="block">
|
||||||
|
|
|
@ -6,7 +6,14 @@
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<header class="block column is-offset-one-quarter pl-1">
|
<header class="block column is-offset-one-quarter pl-1">
|
||||||
|
<div class="columns is-mobile">
|
||||||
|
<div class="column">
|
||||||
<h1 class="title">{% block header %}{% endblock %}</h1>
|
<h1 class="title">{% block header %}{% endblock %}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{% block edit-button %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="block columns">
|
<div class="block columns">
|
||||||
|
|
71
bookwyrm/templates/settings/edit_server.html
Normal file
71
bookwyrm/templates/settings/edit_server.html
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
{% extends 'settings/admin_layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{% trans "Add server" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% trans "Add server" %}
|
||||||
|
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<ul>
|
||||||
|
{% url 'settings-import-blocklist' as url %}
|
||||||
|
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||||
|
<a href="{{ url }}">{% trans "Import block list" %}</a>
|
||||||
|
</li>
|
||||||
|
{% url 'settings-add-federated-server' as url %}
|
||||||
|
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||||
|
<a href="{{ url }}">{% trans "Add server" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="{% url 'settings-add-federated-server' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-half">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="id_server_name">{% trans "Instance:" %}</label>
|
||||||
|
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com">
|
||||||
|
{% for error in form.server_name.errors %}
|
||||||
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="id_status">{% trans "Status:" %}</label>
|
||||||
|
<div class="select">
|
||||||
|
<select name="status" class="" id="id_status">
|
||||||
|
<option value="federated" {% if form.status.value == "federated" %}selected=""{% endif %}>{% trans "Federated" %}</option>
|
||||||
|
<option value="blocked" {% if form.status.value == "blocked" or not form.status.value %}selected{% endif %}>{% trans "Blocked" %}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-half">
|
||||||
|
<div>
|
||||||
|
<label class="label" for="id_application_type">{% trans "Software:" %}</label>
|
||||||
|
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}">
|
||||||
|
{% for error in form.application_type.errors %}
|
||||||
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label" for="id_application_version">{% trans "Version:" %}</label>
|
||||||
|
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
|
||||||
|
{% for error in form.application_version.errors %}
|
||||||
|
<p class="help is-danger">{{ error | escape }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
<label class="label" for="id_notes">{% trans "Notes:" %}</label>
|
||||||
|
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -1,14 +1,20 @@
|
||||||
{% extends 'settings/admin_layout.html' %}
|
{% extends 'settings/admin_layout.html' %}
|
||||||
{% block title %}{{ server.server_name }}{% endblock %}
|
{% block title %}{{ server.server_name }}{% endblock %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load bookwyrm_tags %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{{ server.server_name }}
|
{{ server.server_name }}
|
||||||
|
|
||||||
|
{% if server.status == "blocked" %}<span class="icon icon-x has-text-danger is-size-5" title="{% trans 'Blocked' %}"><span class="is-sr-only">{% trans "Blocked" %}</span></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<section class="block content">
|
<div class="columns">
|
||||||
|
<section class="column is-half content">
|
||||||
<h2 class="title is-4">{% trans "Details" %}</h2>
|
<h2 class="title is-4">{% trans "Details" %}</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<div class="is-flex">
|
<div class="is-flex">
|
||||||
|
@ -21,12 +27,12 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="is-flex">
|
<div class="is-flex">
|
||||||
<dt>{% trans "Status:" %}</dt>
|
<dt>{% trans "Status:" %}</dt>
|
||||||
<dd>Federated</dd>
|
<dd>{{ server.status }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="block content">
|
<section class="column is-half content">
|
||||||
<h2 class="title is-4">{% trans "Activity" %}</h2>
|
<h2 class="title is-4">{% trans "Activity" %}</h2>
|
||||||
<dl>
|
<dl>
|
||||||
<div class="is-flex">
|
<div class="is-flex">
|
||||||
|
@ -62,6 +68,49 @@
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="block content">
|
||||||
|
<header class="columns is-mobile">
|
||||||
|
<div class="column">
|
||||||
|
<h2 class="title is-4 mb-0">{% trans "Notes" %}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="column is-narrow">
|
||||||
|
{% trans "Edit" as button_text %}
|
||||||
|
{% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% if server.notes %}
|
||||||
|
<p id="hide-edit-notes">{{ server.notes|to_markdown|safe }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form class="box is-hidden" method="POST" action="{% url 'settings-federated-server' server.id %}" id="edit-notes">
|
||||||
|
{% csrf_token %}
|
||||||
|
<p>
|
||||||
|
<label class="is-sr-only" for="id_notes">Notes:</label>
|
||||||
|
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ server.notes|default:"" }}</textarea>
|
||||||
|
</p>
|
||||||
|
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||||
|
{% trans "Cancel" as button_text %}
|
||||||
|
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit-notes" %}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="block content">
|
||||||
|
<h2 class="title is-4">{% trans "Actions" %}</h2>
|
||||||
|
{% if server.status != 'blocked' %}
|
||||||
|
<form class="block" method="post" action="{% url 'settings-federated-server-block' server.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="button is-danger">{% trans "Block" %}</button>
|
||||||
|
<p class="help">{% trans "All users from this instance will be deactivated." %}</p>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form class="block" method="post" action="{% url 'settings-federated-server-unblock' server.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button class="button">{% trans "Un-block" %}</button>
|
||||||
|
<p class="help">{% trans "All users from this instance will be re-activated." %}</p>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -4,8 +4,15 @@
|
||||||
|
|
||||||
{% block header %}{% trans "Federated Servers" %}{% endblock %}
|
{% block header %}{% trans "Federated Servers" %}{% endblock %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block edit-button %}
|
||||||
|
<a href="{% url 'settings-import-blocklist' %}">
|
||||||
|
<span class="icon icon-plus" title="{% trans 'Add server' %}">
|
||||||
|
<span class="is-sr-only">{% trans "Add server" %}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
<table class="table is-striped">
|
<table class="table is-striped">
|
||||||
<tr>
|
<tr>
|
||||||
{% url 'settings-federation' as url %}
|
{% url 'settings-federation' as url %}
|
||||||
|
|
67
bookwyrm/templates/settings/server_blocklist.html
Normal file
67
bookwyrm/templates/settings/server_blocklist.html
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
{% extends 'settings/admin_layout.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
{% block title %}{% trans "Add server" %}{% endblock %}
|
||||||
|
|
||||||
|
{% block header %}
|
||||||
|
{% trans "Import Blocklist" %}
|
||||||
|
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block panel %}
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<ul>
|
||||||
|
{% url 'settings-import-blocklist' as url %}
|
||||||
|
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||||
|
<a href="{{ url }}">{% trans "Import block list" %}</a>
|
||||||
|
</li>
|
||||||
|
{% url 'settings-add-federated-server' as url %}
|
||||||
|
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||||
|
<a href="{{ url }}">{% trans "Add server" %}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if succeeded and not failed %}
|
||||||
|
<p class="notification is-primary">{% trans "Success!" %}</p>
|
||||||
|
{% elif succeeded or failed %}
|
||||||
|
<div class="block content">
|
||||||
|
{% if succeeded %}
|
||||||
|
<p>{% trans "Successfully blocked:" %} {{ succeeded }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<p>{% trans "Failed:" %}</p>
|
||||||
|
<ul>
|
||||||
|
{% for item in failed %}
|
||||||
|
<li>
|
||||||
|
<pre>
|
||||||
|
{{ item }}
|
||||||
|
</pre>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="POST" action="{% url 'settings-import-blocklist' %}" enctype="multipart/form-data">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="id_file">JSON data:</label>
|
||||||
|
<aside class="help">
|
||||||
|
Expects a json file in the format provided by <a href="https://fediblock.org/" target="_blank" rel=”noopener”>FediBlock</a>, with a list of entries that have <code>instance</code> and <code>url</code> fields. For example:
|
||||||
|
<pre>
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"instance": "example.server.com",
|
||||||
|
"url": "https://link.to.more/info"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
</pre>
|
||||||
|
</aside>
|
||||||
|
<input type="file" name="json_file" required="" id="id_file">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button is-primary">{% trans "Import" %}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% endblock %}
|
|
@ -1 +1,17 @@
|
||||||
{% for author in book.authors.all %}<a href="/author/{{ author.id }}" class="author">{{ author.name }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}
|
{% spaceless %}
|
||||||
|
{% comment %}
|
||||||
|
@todo The author property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
|
||||||
|
@see https://schema.org/Author
|
||||||
|
{% endcomment %}
|
||||||
|
{% for author in book.authors.all %}
|
||||||
|
<a
|
||||||
|
href="/author/{{ author.id }}"
|
||||||
|
class="author"
|
||||||
|
itemprop="author"
|
||||||
|
itemscope
|
||||||
|
itemtype="https://schema.org/Thing"
|
||||||
|
><span
|
||||||
|
itemprop="name"
|
||||||
|
>{{ author.name }}<span></a>{% if not forloop.last %}, {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% endspaceless %}
|
||||||
|
|
|
@ -1,13 +1,29 @@
|
||||||
|
{% spaceless %}
|
||||||
|
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
<div class="cover-container is-{{ size }}">
|
<div class="cover-container is-{{ size }}">
|
||||||
{% if book.cover %}
|
{% if book.cover %}
|
||||||
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}">
|
<img
|
||||||
{% else %}
|
class="book-cover"
|
||||||
|
src="/images/{{ book.cover }}"
|
||||||
|
alt="{{ book.alt_text }}"
|
||||||
|
title="{{ book.alt_text }}"
|
||||||
|
itemprop="thumbnailUrl"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
<div class="no-cover book-cover">
|
<div class="no-cover book-cover">
|
||||||
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
|
<img
|
||||||
|
class="book-cover"
|
||||||
|
src="/static/images/no_cover.jpg"
|
||||||
|
alt="{% trans "No cover" %}"
|
||||||
|
>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p>{{ book.alt_text }}</p>
|
<p>{{ book.alt_text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endspaceless %}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% with status.id|uuid as uuid %}
|
{% with status.id|uuid as uuid %}
|
||||||
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
||||||
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
|
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
|
||||||
|
@ -10,7 +10,7 @@
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="button is-small is-primary" type="submit">
|
<button class="button is-small is-primary" type="submit">
|
||||||
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}">
|
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}">
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div class="control{% if not parent_status.content_warning and not draft.content_warning %} hidden{% endif %}" id="spoilers-{{ uuid }}">
|
<div class="control{% if not parent_status.content_warning and not draft.content_warning %} is-hidden{% endif %}" id="spoilers-{{ uuid }}">
|
||||||
<label class="is-sr-only" for="id_content_warning-{{ uuid }}">{% trans "Spoiler alert:" %}</label>
|
<label class="is-sr-only" for="id_content_warning-{{ uuid }}">{% trans "Spoiler alert:" %}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
|
@ -73,7 +73,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input type="checkbox" class="hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
|
<input type="checkbox" class="is-hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
|
||||||
{# bottom bar #}
|
{# bottom bar #}
|
||||||
<div class="columns pt-1">
|
<div class="columns pt-1">
|
||||||
<div class="field has-addons column">
|
<div class="field has-addons column">
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% with status.id|uuid as uuid %}
|
{% with status.id|uuid as uuid %}
|
||||||
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="button is-small" type="submit">
|
<button class="button is-small" type="submit">
|
||||||
<span class="icon icon-heart" title="{% trans 'Like status' %}">
|
<span class="icon icon-heart" title="{% trans 'Like status' %}">
|
||||||
|
@ -9,7 +9,7 @@
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button class="button is-primary is-small" type="submit">
|
<button class="button is-primary is-small" type="submit">
|
||||||
<span class="icon icon-heart" title="{% trans 'Un-like status' %}">
|
<span class="icon icon-heart" title="{% trans 'Un-like status' %}">
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form class="hidden mt-3" id="filters" method="get" action="{{ request.path }}" tabindex="0">
|
<form class="is-hidden mt-3" id="filters" method="get" action="{{ request.path }}" tabindex="0">
|
||||||
{% if sort %}
|
{% if sort %}
|
||||||
<input type="hidden" name="sort" value="{{ sort }}">
|
<input type="hidden" name="sort" value="{{ sort }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -6,12 +6,12 @@
|
||||||
|
|
||||||
<div class="field{% if not minimal %} has-addons{% else %} mb-0{% endif %}">
|
<div class="field{% if not minimal %} has-addons{% else %} mb-0{% endif %}">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<form action="{% url 'follow' %}" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all or request.user in user.follower_requests.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
|
<form action="{% url 'follow' %}" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all or request.user in user.follower_requests.all %}is-hidden{%endif %}" data-id="follow-{{ user.id }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="user" value="{{ user.username }}">
|
<input type="hidden" name="user" value="{{ user.username }}">
|
||||||
<button class="button is-small{% if not minimal %} is-link{% endif %}" type="submit">{% trans "Follow" %}</button>
|
<button class="button is-small{% if not minimal %} is-link{% endif %}" type="submit">{% trans "Follow" %}</button>
|
||||||
</form>
|
</form>
|
||||||
<form action="{% url 'unfollow' %}" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all and not request.user in user.follower_requests.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
|
<form action="{% url 'unfollow' %}" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all and not request.user in user.follower_requests.all %}is-hidden{%endif %}" data-id="follow-{{ user.id }}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<input type="hidden" name="user" value="{{ user.username }}">
|
<input type="hidden" name="user" value="{{ user.username }}">
|
||||||
{% if user.manually_approves_followers and request.user not in user.followers.all %}
|
{% if user.manually_approves_followers and request.user not in user.followers.all %}
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
{% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' default_rating=book|user_rating:request.user %}
|
{% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' default_rating=book|user_rating:request.user %}
|
||||||
|
|
||||||
<div class="field has-addons hidden">
|
<div class="field has-addons is-hidden">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
{% include 'snippets/privacy_select.html' with class="is-small" %}
|
{% include 'snippets/privacy_select.html' with class="is-small" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
{% if readthrough.progress %}
|
{% if readthrough.progress %}
|
||||||
{% trans "Show all updates" as button_text %}
|
{% trans "Show all updates" as button_text %}
|
||||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="updates" controls_uid=readthrough.id class="is-small" %}
|
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="updates" controls_uid=readthrough.id class="is-small" %}
|
||||||
<ul id="updates-{{ readthrough.id }}" class="hidden">
|
<ul id="updates-{{ readthrough.id }}" class="is-hidden">
|
||||||
{% for progress_update in readthrough.progress_updates %}
|
{% for progress_update in readthrough.progress_updates %}
|
||||||
<li>
|
<li>
|
||||||
<form name="delete-update" action="/delete-progressupdate" method="POST">
|
<form name="delete-update" action="/delete-progressupdate" method="POST">
|
||||||
|
@ -67,7 +67,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box hidden" id="edit-readthrough-{{ readthrough.id }}" tabindex="0">
|
<div class="box is-hidden" id="edit-readthrough-{{ readthrough.id }}" tabindex="0">
|
||||||
<h3 class="title is-5">{% trans "Edit read dates" %}</h3>
|
<h3 class="title is-5">{% trans "Edit read dates" %}</h3>
|
||||||
<form name="edit-readthrough" action="/edit-readthrough" method="post">
|
<form name="edit-readthrough" action="/edit-readthrough" method="post">
|
||||||
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %}
|
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
{% for shelf in shelves %}
|
{% for shelf in shelves %}
|
||||||
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
|
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
|
||||||
{% if dropdown %}<li role="menuitem">{% endif %}
|
{% if dropdown %}<li role="menuitem">{% endif %}
|
||||||
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}hidden{% endif %}">
|
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}">
|
||||||
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
|
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
|
||||||
{% trans "Start reading" as button_text %}
|
{% trans "Start reading" as button_text %}
|
||||||
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}
|
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}
|
||||||
|
|
|
@ -78,7 +78,7 @@
|
||||||
{% block card-bonus %}
|
{% block card-bonus %}
|
||||||
{% if request.user.is_authenticated and not moderation_mode %}
|
{% if request.user.is_authenticated and not moderation_mode %}
|
||||||
{% with status.id|uuid as uuid %}
|
{% with status.id|uuid as uuid %}
|
||||||
<section class="hidden" id="show-comment-{{ status.id }}">
|
<section class="is-hidden" id="show-comment-{{ status.id }}">
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
<div class="card-footer-item">
|
<div class="card-footer-item">
|
||||||
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
|
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
|
||||||
|
|
|
@ -1,13 +1,46 @@
|
||||||
|
{% spaceless %}
|
||||||
|
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<div class="block">
|
|
||||||
{% if status.status_type == 'Review' or status.status_type == 'Rating' %}
|
{% with status_type=status.status_type %}
|
||||||
|
<div
|
||||||
|
class="block"
|
||||||
|
|
||||||
|
{% if status_type == 'Review' %}
|
||||||
|
{% firstof "reviewBody" as body_prop %}
|
||||||
|
{% firstof 'itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating"' as rating_type %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status_type == 'Rating' %}
|
||||||
|
itemprop="rating"
|
||||||
|
itemtype="https://schema.org/Rating"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
|
{% if status_type == 'Review' or status_type == 'Rating' %}
|
||||||
<div>
|
<div>
|
||||||
{% if status.name %}
|
{% if status.name %}
|
||||||
<h3 class="title is-5 has-subtitle" dir="auto">
|
<h3
|
||||||
|
class="title is-5 has-subtitle"
|
||||||
|
dir="auto"
|
||||||
|
itemprop="name"
|
||||||
|
>
|
||||||
{{ status.name|escape }}
|
{{ status.name|escape }}
|
||||||
</h3>
|
</h3>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="is-sr-only"
|
||||||
|
{{ rating_type }}
|
||||||
|
>
|
||||||
|
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
|
||||||
|
|
||||||
|
{% if status_type == 'Rating' %}
|
||||||
|
{# @todo Is it possible to not hard-code the value? #}
|
||||||
|
<meta itemprop="bestRating" content="5">
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
{% include 'snippets/stars.html' with rating=status.rating %}
|
{% include 'snippets/stars.html' with rating=status.rating %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -15,36 +48,62 @@
|
||||||
{% if status.content_warning %}
|
{% if status.content_warning %}
|
||||||
<div>
|
<div>
|
||||||
<p>{{ status.content_warning }}</p>
|
<p>{{ status.content_warning }}</p>
|
||||||
|
|
||||||
{% trans "Show more" as button_text %}
|
{% trans "Show more" as button_text %}
|
||||||
{% include 'snippets/toggle/open_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
|
|
||||||
|
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
|
||||||
|
{% include 'snippets/toggle/open_button.html' %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div{% if status.content_warning %} class="hidden" id="show-status-cw-{{ status.id }}"{% endif %}>
|
<div
|
||||||
|
{% if status.content_warning %}
|
||||||
|
id="show-status-cw-{{ status.id }}"
|
||||||
|
class="is-hidden"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
{% if status.content_warning %}
|
{% if status.content_warning %}
|
||||||
{% trans "Show less" as button_text %}
|
{% trans "Show less" as button_text %}
|
||||||
{% include 'snippets/toggle/close_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
|
|
||||||
|
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
|
||||||
|
{% include 'snippets/toggle/close_button.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if status.quote %}
|
{% if status.quote %}
|
||||||
<div class="quote block">
|
<div class="quote block">
|
||||||
<blockquote dir="auto" class="mb-2">{{ status.quote | safe }}</blockquote>
|
<blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
|
||||||
|
|
||||||
<p> — {% include 'snippets/book_titleby.html' with book=status.book %}</p>
|
<p> — {% include 'snippets/book_titleby.html' with book=status.book %}</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Announce' %}
|
{% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
|
||||||
{% include 'snippets/trimmed_text.html' with full=status.content|safe no_trim=status.content_warning %}
|
{% with full=status.content|safe no_trim=status.content_warning itemprop=body_prop %}
|
||||||
|
{% include 'snippets/trimmed_text.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if status.attachments.exists %}
|
{% if status.attachments.exists %}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
{% for attachment in status.attachments.all %}
|
{% for attachment in status.attachments.all %}
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<figure class="image is-128x128">
|
<figure class="image is-128x128">
|
||||||
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="{% trans 'Open image in new window' %}">
|
<a
|
||||||
<img src="/images/{{ attachment.image }}"{% if attachment.caption %} alt="{{ attachment.caption }}" title="{{ attachment.caption }}"{% endif %}>
|
href="/images/{{ attachment.image }}"
|
||||||
|
target="_blank"
|
||||||
|
aria-label="{% trans 'Open image in new window' %}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/{{ attachment.image }}"
|
||||||
|
|
||||||
|
{% if attachment.caption %}
|
||||||
|
alt="{{ attachment.caption }}"
|
||||||
|
title="{{ attachment.caption }}"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
|
@ -56,13 +115,23 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not hide_book %}
|
{% if not hide_book %}
|
||||||
{% if status.book or status.mention_books.count %}
|
{% if status.book or status.mention_books.count %}
|
||||||
<div class="{% if status.status_type != 'GeneratedNote' %}box has-background-white-bis{% endif %}">
|
<div
|
||||||
{% if status.book %}
|
{% if status_type != 'GeneratedNote' %}
|
||||||
{% include 'snippets/status/book_preview.html' with book=status.book %}
|
class="box has-background-white-bis"
|
||||||
{% elif status.mention_books.count %}
|
{% endif %}
|
||||||
{% include 'snippets/status/book_preview.html' with book=status.mention_books.first %}
|
>
|
||||||
|
{% if status.book %}
|
||||||
|
{% with book=status.book %}
|
||||||
|
{% include 'snippets/status/book_preview.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
{% elif status.mention_books.count %}
|
||||||
|
{% with book=status.mention_books.first %}
|
||||||
|
{% include 'snippets/status/book_preview.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endspaceless %}
|
||||||
|
|
|
@ -1,9 +1,19 @@
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
<a href="{{ status.user.local_path }}">
|
<span
|
||||||
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
|
itemprop="author"
|
||||||
{{ status.user.display_name }}
|
itemscope
|
||||||
</a>
|
itemtype="https://schema.org/Person"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="{{ status.user.local_path }}"
|
||||||
|
itemprop="url"
|
||||||
|
>
|
||||||
|
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
|
||||||
|
|
||||||
|
<span itemprop="name">{{ status.user.display_name }}</span>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
{% if status.status_type == 'GeneratedNote' %}
|
{% if status.status_type == 'GeneratedNote' %}
|
||||||
{{ status.content | safe }}
|
{{ status.content | safe }}
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
|
{% spaceless %}
|
||||||
{% load bookwyrm_tags %}
|
{% load bookwyrm_tags %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% with 0|uuid as uuid %}
|
{% with 0|uuid as uuid %}
|
||||||
{% if full %}
|
{% if full %}
|
||||||
{% with full|to_markdown|safe as full %}
|
{% with full|to_markdown|safe as full %}
|
||||||
|
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %}
|
||||||
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %}
|
{% if not no_trim and trimmed != full %}
|
||||||
{% if not no_trim and trimmed != full %}
|
<div id="hide-full-{{ uuid }}">
|
||||||
<div id="hide-full-{{ uuid }}">
|
|
||||||
<div class="content" id="trimmed-{{ uuid }}">
|
<div class="content" id="trimmed-{{ uuid }}">
|
||||||
<div dir="auto">{{ trimmed }}</div>
|
<div dir="auto">{{ trimmed }}</div>
|
||||||
|
|
||||||
|
@ -16,25 +16,34 @@
|
||||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="full-{{ uuid }}" class="hidden">
|
<div id="full-{{ uuid }}" class="is-hidden">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div dir="auto">{{ full }}</div>
|
<div
|
||||||
|
dir="auto"
|
||||||
|
{% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
|
||||||
|
>
|
||||||
|
{{ full }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{% trans "Show less" as button_text %}
|
{% trans "Show less" as button_text %}
|
||||||
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div dir="auto">{{ full }}</div>
|
<div
|
||||||
</div>
|
dir="auto"
|
||||||
{% endif %}
|
{% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
|
||||||
|
>
|
||||||
|
{{ full }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
{% endspaceless %}
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
<section class="block content">
|
<section class="block content">
|
||||||
<form name="create-list" method="post" action="{% url 'lists' %}" class="box hidden" id="create-list">
|
<form name="create-list" method="post" action="{% url 'lists' %}" class="box is-hidden" id="create-list">
|
||||||
<header class="columns">
|
<header class="columns">
|
||||||
<h3 class="title column">{% trans "Create list" %}</h3>
|
<h3 class="title column">{% trans "Create list" %}</h3>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" testing models """
|
""" testing models """
|
||||||
|
from unittest.mock import patch
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
@ -9,6 +10,22 @@ from bookwyrm.settings import DOMAIN
|
||||||
class BaseModel(TestCase):
|
class BaseModel(TestCase):
|
||||||
""" functionality shared across models """
|
""" functionality shared across models """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
""" shared data """
|
||||||
|
self.local_user = models.User.objects.create_user(
|
||||||
|
"mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse"
|
||||||
|
)
|
||||||
|
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
||||||
|
self.remote_user = models.User.objects.create_user(
|
||||||
|
"rat",
|
||||||
|
"rat@rat.com",
|
||||||
|
"ratword",
|
||||||
|
local=False,
|
||||||
|
remote_id="https://example.com/users/rat",
|
||||||
|
inbox="https://example.com/users/rat/inbox",
|
||||||
|
outbox="https://example.com/users/rat/outbox",
|
||||||
|
)
|
||||||
|
|
||||||
def test_remote_id(self):
|
def test_remote_id(self):
|
||||||
""" these should be generated """
|
""" these should be generated """
|
||||||
instance = base_model.BookWyrmModel()
|
instance = base_model.BookWyrmModel()
|
||||||
|
@ -18,11 +35,8 @@ class BaseModel(TestCase):
|
||||||
|
|
||||||
def test_remote_id_with_user(self):
|
def test_remote_id_with_user(self):
|
||||||
""" format of remote id when there's a user object """
|
""" format of remote id when there's a user object """
|
||||||
user = models.User.objects.create_user(
|
|
||||||
"mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse"
|
|
||||||
)
|
|
||||||
instance = base_model.BookWyrmModel()
|
instance = base_model.BookWyrmModel()
|
||||||
instance.user = user
|
instance.user = self.local_user
|
||||||
instance.id = 1
|
instance.id = 1
|
||||||
expected = instance.get_remote_id()
|
expected = instance.get_remote_id()
|
||||||
self.assertEqual(expected, "https://%s/user/mouse/bookwyrmmodel/1" % DOMAIN)
|
self.assertEqual(expected, "https://%s/user/mouse/bookwyrmmodel/1" % DOMAIN)
|
||||||
|
@ -42,3 +56,66 @@ class BaseModel(TestCase):
|
||||||
instance.remote_id = None
|
instance.remote_id = None
|
||||||
base_model.set_remote_id(None, instance, False)
|
base_model.set_remote_id(None, instance, False)
|
||||||
self.assertIsNone(instance.remote_id)
|
self.assertIsNone(instance.remote_id)
|
||||||
|
|
||||||
|
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
|
||||||
|
def test_object_visible_to_user(self, _):
|
||||||
|
""" does a user have permission to view an object """
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="public"
|
||||||
|
)
|
||||||
|
self.assertTrue(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
obj = models.Shelf.objects.create(
|
||||||
|
name="test", user=self.remote_user, privacy="unlisted"
|
||||||
|
)
|
||||||
|
self.assertTrue(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="followers"
|
||||||
|
)
|
||||||
|
self.assertFalse(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="direct"
|
||||||
|
)
|
||||||
|
self.assertFalse(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="direct"
|
||||||
|
)
|
||||||
|
obj.mention_users.add(self.local_user)
|
||||||
|
self.assertTrue(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
|
||||||
|
def test_object_visible_to_user_follower(self, _):
|
||||||
|
""" what you can see if you follow a user """
|
||||||
|
self.remote_user.followers.add(self.local_user)
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="followers"
|
||||||
|
)
|
||||||
|
self.assertTrue(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="direct"
|
||||||
|
)
|
||||||
|
self.assertFalse(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="direct"
|
||||||
|
)
|
||||||
|
obj.mention_users.add(self.local_user)
|
||||||
|
self.assertTrue(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
|
||||||
|
def test_object_visible_to_user_blocked(self, _):
|
||||||
|
""" you can't see it if they block you """
|
||||||
|
self.remote_user.blocks.add(self.local_user)
|
||||||
|
obj = models.Status.objects.create(
|
||||||
|
content="hi", user=self.remote_user, privacy="public"
|
||||||
|
)
|
||||||
|
self.assertFalse(obj.visible_to_user(self.local_user))
|
||||||
|
|
||||||
|
obj = models.Shelf.objects.create(
|
||||||
|
name="test", user=self.remote_user, privacy="unlisted"
|
||||||
|
)
|
||||||
|
self.assertFalse(obj.visible_to_user(self.local_user))
|
||||||
|
|
67
bookwyrm/tests/models/test_federated_server.py
Normal file
67
bookwyrm/tests/models/test_federated_server.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
""" testing models """
|
||||||
|
from unittest.mock import patch
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from bookwyrm import models
|
||||||
|
|
||||||
|
|
||||||
|
class FederatedServer(TestCase):
|
||||||
|
""" federate server management """
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
""" we'll need a user """
|
||||||
|
self.server = models.FederatedServer.objects.create(server_name="test.server")
|
||||||
|
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
||||||
|
self.remote_user = models.User.objects.create_user(
|
||||||
|
"rat",
|
||||||
|
"rat@rat.com",
|
||||||
|
"ratword",
|
||||||
|
federated_server=self.server,
|
||||||
|
local=False,
|
||||||
|
remote_id="https://example.com/users/rat",
|
||||||
|
inbox="https://example.com/users/rat/inbox",
|
||||||
|
outbox="https://example.com/users/rat/outbox",
|
||||||
|
)
|
||||||
|
self.inactive_remote_user = models.User.objects.create_user(
|
||||||
|
"nutria",
|
||||||
|
"nutria@nutria.com",
|
||||||
|
"nutriaword",
|
||||||
|
federated_server=self.server,
|
||||||
|
local=False,
|
||||||
|
remote_id="https://example.com/users/nutria",
|
||||||
|
inbox="https://example.com/users/nutria/inbox",
|
||||||
|
outbox="https://example.com/users/nutria/outbox",
|
||||||
|
is_active=False,
|
||||||
|
deactivation_reason="self_deletion",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_block_unblock(self):
|
||||||
|
""" block a server and all users on it """
|
||||||
|
self.assertEqual(self.server.status, "federated")
|
||||||
|
self.assertTrue(self.remote_user.is_active)
|
||||||
|
self.assertFalse(self.inactive_remote_user.is_active)
|
||||||
|
|
||||||
|
self.server.block()
|
||||||
|
|
||||||
|
self.assertEqual(self.server.status, "blocked")
|
||||||
|
self.remote_user.refresh_from_db()
|
||||||
|
self.assertFalse(self.remote_user.is_active)
|
||||||
|
self.assertEqual(self.remote_user.deactivation_reason, "domain_block")
|
||||||
|
|
||||||
|
self.inactive_remote_user.refresh_from_db()
|
||||||
|
self.assertFalse(self.inactive_remote_user.is_active)
|
||||||
|
self.assertEqual(self.inactive_remote_user.deactivation_reason, "self_deletion")
|
||||||
|
|
||||||
|
# UNBLOCK
|
||||||
|
self.server.unblock()
|
||||||
|
|
||||||
|
self.assertEqual(self.server.status, "federated")
|
||||||
|
# user blocked in deactivation is reactivated
|
||||||
|
self.remote_user.refresh_from_db()
|
||||||
|
self.assertTrue(self.remote_user.is_active)
|
||||||
|
self.assertIsNone(self.remote_user.deactivation_reason)
|
||||||
|
|
||||||
|
# deleted user remains deleted
|
||||||
|
self.inactive_remote_user.refresh_from_db()
|
||||||
|
self.assertFalse(self.inactive_remote_user.is_active)
|
||||||
|
self.assertEqual(self.inactive_remote_user.deactivation_reason, "self_deletion")
|
|
@ -11,45 +11,64 @@ class List(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
""" look, a list """
|
""" look, a list """
|
||||||
self.user = models.User.objects.create_user(
|
self.local_user = models.User.objects.create_user(
|
||||||
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
|
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
|
||||||
)
|
)
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
work = models.Work.objects.create(title="hello")
|
||||||
self.list = models.List.objects.create(name="Test List", user=self.user)
|
self.book = models.Edition.objects.create(title="hi", parent_work=work)
|
||||||
|
|
||||||
def test_remote_id(self, _):
|
def test_remote_id(self, _):
|
||||||
""" shelves use custom remote ids """
|
""" shelves use custom remote ids """
|
||||||
expected_id = "https://%s/list/%d" % (settings.DOMAIN, self.list.id)
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
self.assertEqual(self.list.get_remote_id(), expected_id)
|
book_list = models.List.objects.create(
|
||||||
|
name="Test List", user=self.local_user
|
||||||
|
)
|
||||||
|
expected_id = "https://%s/list/%d" % (settings.DOMAIN, book_list.id)
|
||||||
|
self.assertEqual(book_list.get_remote_id(), expected_id)
|
||||||
|
|
||||||
def test_to_activity(self, _):
|
def test_to_activity(self, _):
|
||||||
""" jsonify it """
|
""" jsonify it """
|
||||||
activity_json = self.list.to_activity()
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
|
book_list = models.List.objects.create(
|
||||||
|
name="Test List", user=self.local_user
|
||||||
|
)
|
||||||
|
activity_json = book_list.to_activity()
|
||||||
self.assertIsInstance(activity_json, dict)
|
self.assertIsInstance(activity_json, dict)
|
||||||
self.assertEqual(activity_json["id"], self.list.remote_id)
|
self.assertEqual(activity_json["id"], book_list.remote_id)
|
||||||
self.assertEqual(activity_json["totalItems"], 0)
|
self.assertEqual(activity_json["totalItems"], 0)
|
||||||
self.assertEqual(activity_json["type"], "BookList")
|
self.assertEqual(activity_json["type"], "BookList")
|
||||||
self.assertEqual(activity_json["name"], "Test List")
|
self.assertEqual(activity_json["name"], "Test List")
|
||||||
self.assertEqual(activity_json["owner"], self.user.remote_id)
|
self.assertEqual(activity_json["owner"], self.local_user.remote_id)
|
||||||
|
|
||||||
def test_list_item(self, _):
|
def test_list_item(self, _):
|
||||||
""" a list entry """
|
""" a list entry """
|
||||||
work = models.Work.objects.create(title="hello")
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
book = models.Edition.objects.create(title="hi", parent_work=work)
|
book_list = models.List.objects.create(
|
||||||
|
name="Test List", user=self.local_user, privacy="unlisted"
|
||||||
|
)
|
||||||
|
|
||||||
item = models.ListItem.objects.create(
|
item = models.ListItem.objects.create(
|
||||||
book_list=self.list,
|
book_list=book_list,
|
||||||
book=book,
|
book=self.book,
|
||||||
user=self.user,
|
user=self.local_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(item.approved)
|
self.assertTrue(item.approved)
|
||||||
|
self.assertEqual(item.privacy, "unlisted")
|
||||||
|
self.assertEqual(item.recipients, [self.local_user])
|
||||||
|
|
||||||
add_activity = item.to_add_activity()
|
def test_list_item_pending(self, _):
|
||||||
self.assertEqual(add_activity["actor"], self.user.remote_id)
|
""" a list entry """
|
||||||
self.assertEqual(add_activity["object"]["id"], book.remote_id)
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
self.assertEqual(add_activity["target"], self.list.remote_id)
|
book_list = models.List.objects.create(
|
||||||
|
name="Test List", user=self.local_user
|
||||||
|
)
|
||||||
|
|
||||||
remove_activity = item.to_remove_activity()
|
item = models.ListItem.objects.create(
|
||||||
self.assertEqual(remove_activity["actor"], self.user.remote_id)
|
book_list=book_list, book=self.book, user=self.local_user, approved=False
|
||||||
self.assertEqual(remove_activity["object"]["id"], book.remote_id)
|
)
|
||||||
self.assertEqual(remove_activity["target"], self.list.remote_id)
|
|
||||||
|
self.assertFalse(item.approved)
|
||||||
|
self.assertEqual(item.book_list.privacy, "public")
|
||||||
|
self.assertEqual(item.privacy, "direct")
|
||||||
|
self.assertEqual(item.recipients, [self.local_user])
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
""" testing models """
|
""" testing models """
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models, settings
|
||||||
|
@ -18,27 +20,16 @@ class Shelf(TestCase):
|
||||||
|
|
||||||
def test_remote_id(self):
|
def test_remote_id(self):
|
||||||
""" shelves use custom remote ids """
|
""" shelves use custom remote ids """
|
||||||
real_broadcast = models.Shelf.broadcast
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
|
|
||||||
def broadcast_mock(_, activity, user, **kwargs):
|
|
||||||
""" nah """
|
|
||||||
|
|
||||||
models.Shelf.broadcast = broadcast_mock
|
|
||||||
shelf = models.Shelf.objects.create(
|
shelf = models.Shelf.objects.create(
|
||||||
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
||||||
)
|
)
|
||||||
expected_id = "https://%s/user/mouse/books/test-shelf" % settings.DOMAIN
|
expected_id = "https://%s/user/mouse/books/test-shelf" % settings.DOMAIN
|
||||||
self.assertEqual(shelf.get_remote_id(), expected_id)
|
self.assertEqual(shelf.get_remote_id(), expected_id)
|
||||||
models.Shelf.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_to_activity(self):
|
def test_to_activity(self):
|
||||||
""" jsonify it """
|
""" jsonify it """
|
||||||
real_broadcast = models.Shelf.broadcast
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
|
|
||||||
def empty_mock(_, activity, user, **kwargs):
|
|
||||||
""" nah """
|
|
||||||
|
|
||||||
models.Shelf.broadcast = empty_mock
|
|
||||||
shelf = models.Shelf.objects.create(
|
shelf = models.Shelf.objects.create(
|
||||||
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
||||||
)
|
)
|
||||||
|
@ -49,77 +40,53 @@ class Shelf(TestCase):
|
||||||
self.assertEqual(activity_json["type"], "Shelf")
|
self.assertEqual(activity_json["type"], "Shelf")
|
||||||
self.assertEqual(activity_json["name"], "Test Shelf")
|
self.assertEqual(activity_json["name"], "Test Shelf")
|
||||||
self.assertEqual(activity_json["owner"], self.local_user.remote_id)
|
self.assertEqual(activity_json["owner"], self.local_user.remote_id)
|
||||||
models.Shelf.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_create_update_shelf(self):
|
def test_create_update_shelf(self):
|
||||||
""" create and broadcast shelf creation """
|
""" create and broadcast shelf creation """
|
||||||
real_broadcast = models.Shelf.broadcast
|
|
||||||
|
|
||||||
def create_mock(_, activity, user, **kwargs):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
""" ok """
|
shelf = models.Shelf.objects.create(
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
||||||
|
)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
self.assertEqual(activity["type"], "Create")
|
self.assertEqual(activity["type"], "Create")
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
self.assertEqual(activity["object"]["name"], "Test Shelf")
|
self.assertEqual(activity["object"]["name"], "Test Shelf")
|
||||||
|
|
||||||
models.Shelf.broadcast = create_mock
|
shelf.name = "arthur russel"
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
shelf = models.Shelf.objects.create(
|
shelf.save()
|
||||||
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
activity = json.loads(mock.call_args[0][1])
|
||||||
)
|
|
||||||
|
|
||||||
def update_mock(_, activity, user, **kwargs):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Update")
|
self.assertEqual(activity["type"], "Update")
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
self.assertEqual(activity["object"]["name"], "arthur russel")
|
self.assertEqual(activity["object"]["name"], "arthur russel")
|
||||||
|
|
||||||
models.Shelf.broadcast = update_mock
|
|
||||||
|
|
||||||
shelf.name = "arthur russel"
|
|
||||||
shelf.save()
|
|
||||||
self.assertEqual(shelf.name, "arthur russel")
|
self.assertEqual(shelf.name, "arthur russel")
|
||||||
models.Shelf.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_shelve(self):
|
def test_shelve(self):
|
||||||
""" create and broadcast shelf creation """
|
""" create and broadcast shelf creation """
|
||||||
real_broadcast = models.Shelf.broadcast
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
real_shelfbook_broadcast = models.ShelfBook.broadcast
|
|
||||||
|
|
||||||
def add_mock(_, activity, user, **kwargs):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Add")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["object"]["id"], self.book.remote_id)
|
|
||||||
self.assertEqual(activity["target"], shelf.remote_id)
|
|
||||||
|
|
||||||
def remove_mock(_, activity, user, **kwargs):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Remove")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["object"]["id"], self.book.remote_id)
|
|
||||||
self.assertEqual(activity["target"], shelf.remote_id)
|
|
||||||
|
|
||||||
def empty_mock(_, activity, user, **kwargs):
|
|
||||||
""" nah """
|
|
||||||
|
|
||||||
models.Shelf.broadcast = empty_mock
|
|
||||||
shelf = models.Shelf.objects.create(
|
shelf = models.Shelf.objects.create(
|
||||||
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
name="Test Shelf", identifier="test-shelf", user=self.local_user
|
||||||
)
|
)
|
||||||
|
|
||||||
models.ShelfBook.broadcast = add_mock
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
shelf_book = models.ShelfBook.objects.create(
|
shelf_book = models.ShelfBook.objects.create(
|
||||||
shelf=shelf, user=self.local_user, book=self.book
|
shelf=shelf, user=self.local_user, book=self.book
|
||||||
)
|
)
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Add")
|
||||||
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
|
self.assertEqual(activity["object"]["id"], shelf_book.remote_id)
|
||||||
|
self.assertEqual(activity["target"], shelf.remote_id)
|
||||||
self.assertEqual(shelf.books.first(), self.book)
|
self.assertEqual(shelf.books.first(), self.book)
|
||||||
|
|
||||||
models.ShelfBook.broadcast = remove_mock
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
shelf_book.delete()
|
shelf_book.delete()
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Remove")
|
||||||
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
|
self.assertEqual(activity["object"]["id"], shelf_book.remote_id)
|
||||||
|
self.assertEqual(activity["target"], shelf.remote_id)
|
||||||
self.assertFalse(shelf.books.exists())
|
self.assertFalse(shelf.books.exists())
|
||||||
|
|
||||||
models.ShelfBook.broadcast = real_shelfbook_broadcast
|
|
||||||
models.Shelf.broadcast = real_broadcast
|
|
||||||
|
|
|
@ -4,8 +4,9 @@ from unittest.mock import patch
|
||||||
|
|
||||||
from django.http import HttpResponseNotAllowed, HttpResponseNotFound
|
from django.http import HttpResponseNotAllowed, HttpResponseNotFound
|
||||||
from django.test import TestCase, Client
|
from django.test import TestCase, Client
|
||||||
|
from django.test.client import RequestFactory
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models, views
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
# pylint: disable=too-many-public-methods
|
||||||
|
@ -15,6 +16,7 @@ class Inbox(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
""" basic user and book data """
|
""" basic user and book data """
|
||||||
self.client = Client()
|
self.client = Client()
|
||||||
|
self.factory = RequestFactory()
|
||||||
local_user = models.User.objects.create_user(
|
local_user = models.User.objects.create_user(
|
||||||
"mouse@example.com",
|
"mouse@example.com",
|
||||||
"mouse@mouse.com",
|
"mouse@mouse.com",
|
||||||
|
@ -106,3 +108,26 @@ class Inbox(TestCase):
|
||||||
"/inbox", json.dumps(activity), content_type="application/json"
|
"/inbox", json.dumps(activity), content_type="application/json"
|
||||||
)
|
)
|
||||||
self.assertEqual(result.status_code, 200)
|
self.assertEqual(result.status_code, 200)
|
||||||
|
|
||||||
|
def test_is_blocked_user_agent(self):
|
||||||
|
""" check for blocked servers """
|
||||||
|
request = self.factory.post(
|
||||||
|
"",
|
||||||
|
HTTP_USER_AGENT="http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)",
|
||||||
|
)
|
||||||
|
self.assertFalse(views.inbox.is_blocked_user_agent(request))
|
||||||
|
|
||||||
|
models.FederatedServer.objects.create(
|
||||||
|
server_name="mastodon.social", status="blocked"
|
||||||
|
)
|
||||||
|
self.assertTrue(views.inbox.is_blocked_user_agent(request))
|
||||||
|
|
||||||
|
def test_is_blocked_activity(self):
|
||||||
|
""" check for blocked servers """
|
||||||
|
activity = {"actor": "https://mastodon.social/user/whaatever/else"}
|
||||||
|
self.assertFalse(views.inbox.is_blocked_activity(activity))
|
||||||
|
|
||||||
|
models.FederatedServer.objects.create(
|
||||||
|
server_name="mastodon.social", status="blocked"
|
||||||
|
)
|
||||||
|
self.assertTrue(views.inbox.is_blocked_activity(activity))
|
||||||
|
|
|
@ -8,20 +8,20 @@ from bookwyrm import models, views
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
# pylint: disable=too-many-public-methods
|
||||||
class InboxActivities(TestCase):
|
class InboxAdd(TestCase):
|
||||||
""" inbox tests """
|
""" inbox tests """
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
""" basic user and book data """
|
""" basic user and book data """
|
||||||
self.local_user = models.User.objects.create_user(
|
local_user = models.User.objects.create_user(
|
||||||
"mouse@example.com",
|
"mouse@example.com",
|
||||||
"mouse@mouse.com",
|
"mouse@mouse.com",
|
||||||
"mouseword",
|
"mouseword",
|
||||||
local=True,
|
local=True,
|
||||||
localname="mouse",
|
localname="mouse",
|
||||||
)
|
)
|
||||||
self.local_user.remote_id = "https://example.com/user/mouse"
|
local_user.remote_id = "https://example.com/user/mouse"
|
||||||
self.local_user.save(broadcast=False)
|
local_user.save(broadcast=False)
|
||||||
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
||||||
self.remote_user = models.User.objects.create_user(
|
self.remote_user = models.User.objects.create_user(
|
||||||
"rat",
|
"rat",
|
||||||
|
@ -32,17 +32,17 @@ class InboxActivities(TestCase):
|
||||||
inbox="https://example.com/users/rat/inbox",
|
inbox="https://example.com/users/rat/inbox",
|
||||||
outbox="https://example.com/users/rat/outbox",
|
outbox="https://example.com/users/rat/outbox",
|
||||||
)
|
)
|
||||||
|
work = models.Work.objects.create(title="work title")
|
||||||
|
self.book = models.Edition.objects.create(
|
||||||
|
title="Test",
|
||||||
|
remote_id="https://bookwyrm.social/book/37292",
|
||||||
|
parent_work=work,
|
||||||
|
)
|
||||||
|
|
||||||
models.SiteSettings.objects.create()
|
models.SiteSettings.objects.create()
|
||||||
|
|
||||||
def test_handle_add_book_to_shelf(self):
|
def test_handle_add_book_to_shelf(self):
|
||||||
""" shelving a book """
|
""" shelving a book """
|
||||||
work = models.Work.objects.create(title="work title")
|
|
||||||
book = models.Edition.objects.create(
|
|
||||||
title="Test",
|
|
||||||
remote_id="https://bookwyrm.social/book/37292",
|
|
||||||
parent_work=work,
|
|
||||||
)
|
|
||||||
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
|
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
|
||||||
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
|
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
|
||||||
shelf.save()
|
shelf.save()
|
||||||
|
@ -52,27 +52,20 @@ class InboxActivities(TestCase):
|
||||||
"type": "Add",
|
"type": "Add",
|
||||||
"actor": "https://example.com/users/rat",
|
"actor": "https://example.com/users/rat",
|
||||||
"object": {
|
"object": {
|
||||||
"type": "Edition",
|
"actor": self.remote_user.remote_id,
|
||||||
"title": "Test Title",
|
"type": "ShelfItem",
|
||||||
"work": work.remote_id,
|
"book": self.book.remote_id,
|
||||||
"id": "https://bookwyrm.social/book/37292",
|
"id": "https://bookwyrm.social/shelfbook/6189",
|
||||||
},
|
},
|
||||||
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
|
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
}
|
}
|
||||||
views.inbox.activity_task(activity)
|
views.inbox.activity_task(activity)
|
||||||
self.assertEqual(shelf.books.first(), book)
|
self.assertEqual(shelf.books.first(), self.book)
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_handle_add_book_to_list(self):
|
def test_handle_add_book_to_list(self):
|
||||||
""" listing a book """
|
""" listing a book """
|
||||||
work = models.Work.objects.create(title="work title")
|
|
||||||
book = models.Edition.objects.create(
|
|
||||||
title="Test",
|
|
||||||
remote_id="https://bookwyrm.social/book/37292",
|
|
||||||
parent_work=work,
|
|
||||||
)
|
|
||||||
|
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET,
|
responses.GET,
|
||||||
"https://bookwyrm.social/user/mouse/list/to-read",
|
"https://bookwyrm.social/user/mouse/list/to-read",
|
||||||
|
@ -97,10 +90,10 @@ class InboxActivities(TestCase):
|
||||||
"type": "Add",
|
"type": "Add",
|
||||||
"actor": "https://example.com/users/rat",
|
"actor": "https://example.com/users/rat",
|
||||||
"object": {
|
"object": {
|
||||||
"type": "Edition",
|
"actor": self.remote_user.remote_id,
|
||||||
"title": "Test Title",
|
"type": "ListItem",
|
||||||
"work": work.remote_id,
|
"book": self.book.remote_id,
|
||||||
"id": "https://bookwyrm.social/book/37292",
|
"id": "https://bookwyrm.social/listbook/6189",
|
||||||
},
|
},
|
||||||
"target": "https://bookwyrm.social/user/mouse/list/to-read",
|
"target": "https://bookwyrm.social/user/mouse/list/to-read",
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
@ -108,49 +101,7 @@ class InboxActivities(TestCase):
|
||||||
views.inbox.activity_task(activity)
|
views.inbox.activity_task(activity)
|
||||||
|
|
||||||
booklist = models.List.objects.get()
|
booklist = models.List.objects.get()
|
||||||
|
listitem = models.ListItem.objects.get()
|
||||||
self.assertEqual(booklist.name, "Test List")
|
self.assertEqual(booklist.name, "Test List")
|
||||||
self.assertEqual(booklist.books.first(), book)
|
self.assertEqual(booklist.books.first(), self.book)
|
||||||
|
self.assertEqual(listitem.remote_id, "https://bookwyrm.social/listbook/6189")
|
||||||
@responses.activate
|
|
||||||
def test_handle_tag_book(self):
|
|
||||||
""" listing a book """
|
|
||||||
work = models.Work.objects.create(title="work title")
|
|
||||||
book = models.Edition.objects.create(
|
|
||||||
title="Test",
|
|
||||||
remote_id="https://bookwyrm.social/book/37292",
|
|
||||||
parent_work=work,
|
|
||||||
)
|
|
||||||
|
|
||||||
responses.add(
|
|
||||||
responses.GET,
|
|
||||||
"https://www.example.com/tag/cool-tag",
|
|
||||||
json={
|
|
||||||
"id": "https://1b1a78582461.ngrok.io/tag/tag",
|
|
||||||
"type": "OrderedCollection",
|
|
||||||
"totalItems": 0,
|
|
||||||
"first": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
|
|
||||||
"last": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
|
|
||||||
"name": "cool tag",
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
activity = {
|
|
||||||
"id": "https://bookwyrm.social/listbook/6189#add",
|
|
||||||
"type": "Add",
|
|
||||||
"actor": "https://example.com/users/rat",
|
|
||||||
"object": {
|
|
||||||
"type": "Edition",
|
|
||||||
"title": "Test Title",
|
|
||||||
"work": work.remote_id,
|
|
||||||
"id": "https://bookwyrm.social/book/37292",
|
|
||||||
},
|
|
||||||
"target": "https://www.example.com/tag/cool-tag",
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
|
||||||
}
|
|
||||||
views.inbox.activity_task(activity)
|
|
||||||
|
|
||||||
tag = models.Tag.objects.get()
|
|
||||||
self.assertFalse(models.List.objects.exists())
|
|
||||||
self.assertEqual(tag.name, "cool tag")
|
|
||||||
self.assertEqual(tag.books.first(), book)
|
|
||||||
|
|
|
@ -136,6 +136,9 @@ class InboxActivities(TestCase):
|
||||||
"id": "http://www.faraway.com/boost/12",
|
"id": "http://www.faraway.com/boost/12",
|
||||||
"actor": self.remote_user.remote_id,
|
"actor": self.remote_user.remote_id,
|
||||||
"object": status.remote_id,
|
"object": status.remote_id,
|
||||||
|
"to": ["https://www.w3.org/ns/activitystreams#public"],
|
||||||
|
"cc": ["https://example.com/user/mouse/followers"],
|
||||||
|
"published": "Mon, 25 May 2020 19:31:20 GMT",
|
||||||
}
|
}
|
||||||
responses.add(
|
responses.add(
|
||||||
responses.GET, status.remote_id, json=status.to_activity(), status=200
|
responses.GET, status.remote_id, json=status.to_activity(), status=200
|
||||||
|
@ -185,6 +188,7 @@ class InboxActivities(TestCase):
|
||||||
"id": "http://fake.com/unknown/boost",
|
"id": "http://fake.com/unknown/boost",
|
||||||
"actor": self.remote_user.remote_id,
|
"actor": self.remote_user.remote_id,
|
||||||
"object": self.status.remote_id,
|
"object": self.status.remote_id,
|
||||||
|
"published": "Mon, 25 May 2020 19:31:20 GMT",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
views.inbox.activity_task(activity)
|
views.inbox.activity_task(activity)
|
||||||
|
|
|
@ -9,7 +9,7 @@ from bookwyrm import models, views
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
# pylint: disable=too-many-public-methods
|
||||||
class InboxActivities(TestCase):
|
class InboxCreate(TestCase):
|
||||||
""" readthrough tests """
|
""" readthrough tests """
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
|
@ -7,11 +7,20 @@ from bookwyrm import models, views
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
# pylint: disable=too-many-public-methods
|
||||||
class InboxActivities(TestCase):
|
class InboxRemove(TestCase):
|
||||||
""" inbox tests """
|
""" inbox tests """
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
""" basic user and book data """
|
""" basic user and book data """
|
||||||
|
self.local_user = models.User.objects.create_user(
|
||||||
|
"mouse@example.com",
|
||||||
|
"mouse@mouse.com",
|
||||||
|
"mouseword",
|
||||||
|
local=True,
|
||||||
|
localname="mouse",
|
||||||
|
)
|
||||||
|
self.local_user.remote_id = "https://example.com/user/mouse"
|
||||||
|
self.local_user.save(broadcast=False)
|
||||||
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
||||||
self.remote_user = models.User.objects.create_user(
|
self.remote_user = models.User.objects.create_user(
|
||||||
"rat",
|
"rat",
|
||||||
|
@ -22,26 +31,26 @@ class InboxActivities(TestCase):
|
||||||
inbox="https://example.com/users/rat/inbox",
|
inbox="https://example.com/users/rat/inbox",
|
||||||
outbox="https://example.com/users/rat/outbox",
|
outbox="https://example.com/users/rat/outbox",
|
||||||
)
|
)
|
||||||
|
self.work = models.Work.objects.create(title="work title")
|
||||||
|
self.book = models.Edition.objects.create(
|
||||||
|
title="Test",
|
||||||
|
remote_id="https://bookwyrm.social/book/37292",
|
||||||
|
parent_work=self.work,
|
||||||
|
)
|
||||||
|
|
||||||
models.SiteSettings.objects.create()
|
models.SiteSettings.objects.create()
|
||||||
|
|
||||||
def test_handle_unshelve_book(self):
|
def test_handle_unshelve_book(self):
|
||||||
""" remove a book from a shelf """
|
""" remove a book from a shelf """
|
||||||
work = models.Work.objects.create(title="work title")
|
|
||||||
book = models.Edition.objects.create(
|
|
||||||
title="Test",
|
|
||||||
remote_id="https://bookwyrm.social/book/37292",
|
|
||||||
parent_work=work,
|
|
||||||
)
|
|
||||||
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
|
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
|
||||||
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
|
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
|
||||||
shelf.save()
|
shelf.save()
|
||||||
|
|
||||||
shelfbook = models.ShelfBook.objects.create(
|
shelfbook = models.ShelfBook.objects.create(
|
||||||
user=self.remote_user, shelf=shelf, book=book
|
user=self.remote_user, shelf=shelf, book=self.book
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(shelf.books.first(), book)
|
self.assertEqual(shelf.books.first(), self.book)
|
||||||
self.assertEqual(shelf.books.count(), 1)
|
self.assertEqual(shelf.books.count(), 1)
|
||||||
|
|
||||||
activity = {
|
activity = {
|
||||||
|
@ -49,13 +58,44 @@ class InboxActivities(TestCase):
|
||||||
"type": "Remove",
|
"type": "Remove",
|
||||||
"actor": "https://example.com/users/rat",
|
"actor": "https://example.com/users/rat",
|
||||||
"object": {
|
"object": {
|
||||||
"type": "Edition",
|
"actor": self.remote_user.remote_id,
|
||||||
"title": "Test Title",
|
"type": "ShelfItem",
|
||||||
"work": work.remote_id,
|
"book": self.book.remote_id,
|
||||||
"id": "https://bookwyrm.social/book/37292",
|
"id": shelfbook.remote_id,
|
||||||
},
|
},
|
||||||
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
|
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
}
|
}
|
||||||
views.inbox.activity_task(activity)
|
views.inbox.activity_task(activity)
|
||||||
self.assertFalse(shelf.books.exists())
|
self.assertFalse(shelf.books.exists())
|
||||||
|
|
||||||
|
def test_handle_remove_book_from_list(self):
|
||||||
|
""" listing a book """
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
|
booklist = models.List.objects.create(
|
||||||
|
name="test list",
|
||||||
|
user=self.local_user,
|
||||||
|
)
|
||||||
|
listitem = models.ListItem.objects.create(
|
||||||
|
user=self.local_user,
|
||||||
|
book=self.book,
|
||||||
|
book_list=booklist,
|
||||||
|
)
|
||||||
|
self.assertEqual(booklist.books.count(), 1)
|
||||||
|
|
||||||
|
activity = {
|
||||||
|
"id": listitem.remote_id,
|
||||||
|
"type": "Remove",
|
||||||
|
"actor": "https://example.com/users/rat",
|
||||||
|
"object": {
|
||||||
|
"actor": self.remote_user.remote_id,
|
||||||
|
"type": "ListItem",
|
||||||
|
"book": self.book.remote_id,
|
||||||
|
"id": listitem.remote_id,
|
||||||
|
},
|
||||||
|
"target": booklist.remote_id,
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
}
|
||||||
|
views.inbox.activity_task(activity)
|
||||||
|
|
||||||
|
self.assertEqual(booklist.books.count(), 0)
|
||||||
|
|
|
@ -47,6 +47,39 @@ class BookViews(TestCase):
|
||||||
)
|
)
|
||||||
models.SiteSettings.objects.create()
|
models.SiteSettings.objects.create()
|
||||||
|
|
||||||
|
def test_date_regression(self):
|
||||||
|
"""ensure that creating a new book actually saves the published date fields
|
||||||
|
|
||||||
|
this was initially a regression due to using a custom date picker tag
|
||||||
|
"""
|
||||||
|
first_published_date = "2021-04-20"
|
||||||
|
published_date = "2022-04-20"
|
||||||
|
self.local_user.groups.add(self.group)
|
||||||
|
view = views.EditBook.as_view()
|
||||||
|
form = forms.EditionForm(
|
||||||
|
{
|
||||||
|
"title": "New Title",
|
||||||
|
"last_edited_by": self.local_user.id,
|
||||||
|
"first_published_date": first_published_date,
|
||||||
|
"published_date": published_date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
request = self.factory.post("", form.data)
|
||||||
|
request.user = self.local_user
|
||||||
|
|
||||||
|
with patch("bookwyrm.connectors.connector_manager.local_search"):
|
||||||
|
result = view(request)
|
||||||
|
result.render()
|
||||||
|
|
||||||
|
self.assertContains(
|
||||||
|
result,
|
||||||
|
f'<input type="date" name="first_published_date" class="input" id="id_first_published_date" value="{first_published_date}">',
|
||||||
|
)
|
||||||
|
self.assertContains(
|
||||||
|
result,
|
||||||
|
f'<input type="date" name="published_date" class="input" id="id_published_date" value="{published_date}">',
|
||||||
|
)
|
||||||
|
|
||||||
def test_book_page(self):
|
def test_book_page(self):
|
||||||
""" there are so many views, this just makes sure it LOADS """
|
""" there are so many views, this just makes sure it LOADS """
|
||||||
view = views.Book.as_view()
|
view = views.Book.as_view()
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
""" test for app action functionality """
|
""" test for app action functionality """
|
||||||
|
import json
|
||||||
|
from unittest.mock import patch
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
|
|
||||||
from bookwyrm import models, views
|
from bookwyrm import forms, models, views
|
||||||
|
|
||||||
|
|
||||||
class FederationViews(TestCase):
|
class FederationViews(TestCase):
|
||||||
|
@ -19,6 +22,16 @@ class FederationViews(TestCase):
|
||||||
local=True,
|
local=True,
|
||||||
localname="mouse",
|
localname="mouse",
|
||||||
)
|
)
|
||||||
|
with patch("bookwyrm.models.user.set_remote_server.delay"):
|
||||||
|
self.remote_user = models.User.objects.create_user(
|
||||||
|
"rat",
|
||||||
|
"rat@rat.com",
|
||||||
|
"ratword",
|
||||||
|
local=False,
|
||||||
|
remote_id="https://example.com/users/rat",
|
||||||
|
inbox="https://example.com/users/rat/inbox",
|
||||||
|
outbox="https://example.com/users/rat/outbox",
|
||||||
|
)
|
||||||
models.SiteSettings.objects.create()
|
models.SiteSettings.objects.create()
|
||||||
|
|
||||||
def test_federation_page(self):
|
def test_federation_page(self):
|
||||||
|
@ -44,3 +57,111 @@ class FederationViews(TestCase):
|
||||||
self.assertIsInstance(result, TemplateResponse)
|
self.assertIsInstance(result, TemplateResponse)
|
||||||
result.render()
|
result.render()
|
||||||
self.assertEqual(result.status_code, 200)
|
self.assertEqual(result.status_code, 200)
|
||||||
|
|
||||||
|
def test_server_page_block(self):
|
||||||
|
""" block a server """
|
||||||
|
server = models.FederatedServer.objects.create(server_name="hi.there.com")
|
||||||
|
self.remote_user.federated_server = server
|
||||||
|
self.remote_user.save()
|
||||||
|
|
||||||
|
self.assertEqual(server.status, "federated")
|
||||||
|
|
||||||
|
view = views.federation.block_server
|
||||||
|
request = self.factory.post("")
|
||||||
|
request.user = self.local_user
|
||||||
|
request.user.is_superuser = True
|
||||||
|
|
||||||
|
view(request, server.id)
|
||||||
|
server.refresh_from_db()
|
||||||
|
self.remote_user.refresh_from_db()
|
||||||
|
self.assertEqual(server.status, "blocked")
|
||||||
|
# and the user was deactivated
|
||||||
|
self.assertFalse(self.remote_user.is_active)
|
||||||
|
|
||||||
|
def test_server_page_unblock(self):
|
||||||
|
""" unblock a server """
|
||||||
|
server = models.FederatedServer.objects.create(
|
||||||
|
server_name="hi.there.com", status="blocked"
|
||||||
|
)
|
||||||
|
self.remote_user.federated_server = server
|
||||||
|
self.remote_user.is_active = False
|
||||||
|
self.remote_user.deactivation_reason = "domain_block"
|
||||||
|
self.remote_user.save()
|
||||||
|
|
||||||
|
request = self.factory.post("")
|
||||||
|
request.user = self.local_user
|
||||||
|
request.user.is_superuser = True
|
||||||
|
|
||||||
|
views.federation.unblock_server(request, server.id)
|
||||||
|
server.refresh_from_db()
|
||||||
|
self.remote_user.refresh_from_db()
|
||||||
|
self.assertEqual(server.status, "federated")
|
||||||
|
# and the user was re-activated
|
||||||
|
self.assertTrue(self.remote_user.is_active)
|
||||||
|
|
||||||
|
def test_add_view_get(self):
|
||||||
|
""" there are so many views, this just makes sure it LOADS """
|
||||||
|
# create mode
|
||||||
|
view = views.AddFederatedServer.as_view()
|
||||||
|
request = self.factory.get("")
|
||||||
|
request.user = self.local_user
|
||||||
|
request.user.is_superuser = True
|
||||||
|
|
||||||
|
result = view(request)
|
||||||
|
self.assertIsInstance(result, TemplateResponse)
|
||||||
|
result.render()
|
||||||
|
self.assertEqual(result.status_code, 200)
|
||||||
|
|
||||||
|
def test_add_view_post_create(self):
|
||||||
|
""" create a server entry """
|
||||||
|
form = forms.ServerForm()
|
||||||
|
form.data["server_name"] = "remote.server"
|
||||||
|
form.data["application_type"] = "coolsoft"
|
||||||
|
form.data["status"] = "blocked"
|
||||||
|
|
||||||
|
view = views.AddFederatedServer.as_view()
|
||||||
|
request = self.factory.post("", form.data)
|
||||||
|
request.user = self.local_user
|
||||||
|
request.user.is_superuser = True
|
||||||
|
|
||||||
|
view(request)
|
||||||
|
server = models.FederatedServer.objects.get()
|
||||||
|
self.assertEqual(server.server_name, "remote.server")
|
||||||
|
self.assertEqual(server.application_type, "coolsoft")
|
||||||
|
self.assertEqual(server.status, "blocked")
|
||||||
|
|
||||||
|
def test_import_blocklist(self):
|
||||||
|
""" load a json file with a list of servers to block """
|
||||||
|
server = models.FederatedServer.objects.create(server_name="hi.there.com")
|
||||||
|
self.remote_user.federated_server = server
|
||||||
|
self.remote_user.save()
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{"instance": "server.name", "url": "https://explanation.url"}, # new server
|
||||||
|
{"instance": "hi.there.com", "url": "https://explanation.url"}, # existing
|
||||||
|
{"a": "b"}, # invalid
|
||||||
|
]
|
||||||
|
json.dump(data, open("file.json", "w"))
|
||||||
|
|
||||||
|
view = views.ImportServerBlocklist.as_view()
|
||||||
|
request = self.factory.post(
|
||||||
|
"",
|
||||||
|
{
|
||||||
|
"json_file": SimpleUploadedFile(
|
||||||
|
"file.json", open("file.json", "rb").read()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
request.user = self.local_user
|
||||||
|
request.user.is_superuser = True
|
||||||
|
|
||||||
|
view(request)
|
||||||
|
server.refresh_from_db()
|
||||||
|
self.remote_user.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(models.FederatedServer.objects.count(), 2)
|
||||||
|
self.assertEqual(server.status, "blocked")
|
||||||
|
self.assertFalse(self.remote_user.is_active)
|
||||||
|
created = models.FederatedServer.objects.get(server_name="server.name")
|
||||||
|
self.assertEqual(created.status, "blocked")
|
||||||
|
self.assertEqual(created.notes, "https://explanation.url")
|
||||||
|
|
|
@ -146,6 +146,15 @@ class ViewsHelpers(TestCase):
|
||||||
self.assertIsInstance(result, models.User)
|
self.assertIsInstance(result, models.User)
|
||||||
self.assertEqual(result.username, "mouse@example.com")
|
self.assertEqual(result.username, "mouse@example.com")
|
||||||
|
|
||||||
|
def test_user_on_blocked_server(self, _):
|
||||||
|
""" find a remote user using webfinger """
|
||||||
|
models.FederatedServer.objects.create(
|
||||||
|
server_name="example.com", status="blocked"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = views.helpers.handle_remote_webfinger("@mouse@example.com")
|
||||||
|
self.assertIsNone(result)
|
||||||
|
|
||||||
def test_handle_reading_status_to_read(self, _):
|
def test_handle_reading_status_to_read(self, _):
|
||||||
""" posts shelve activities """
|
""" posts shelve activities """
|
||||||
shelf = self.local_user.shelf_set.get(identifier="to-read")
|
shelf = self.local_user.shelf_set.get(identifier="to-read")
|
||||||
|
@ -190,66 +199,6 @@ class ViewsHelpers(TestCase):
|
||||||
)
|
)
|
||||||
self.assertFalse(models.GeneratedNote.objects.exists())
|
self.assertFalse(models.GeneratedNote.objects.exists())
|
||||||
|
|
||||||
def test_object_visible_to_user(self, _):
|
|
||||||
""" does a user have permission to view an object """
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="public"
|
|
||||||
)
|
|
||||||
self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
obj = models.Shelf.objects.create(
|
|
||||||
name="test", user=self.remote_user, privacy="unlisted"
|
|
||||||
)
|
|
||||||
self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="followers"
|
|
||||||
)
|
|
||||||
self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="direct"
|
|
||||||
)
|
|
||||||
self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="direct"
|
|
||||||
)
|
|
||||||
obj.mention_users.add(self.local_user)
|
|
||||||
self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
def test_object_visible_to_user_follower(self, _):
|
|
||||||
""" what you can see if you follow a user """
|
|
||||||
self.remote_user.followers.add(self.local_user)
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="followers"
|
|
||||||
)
|
|
||||||
self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="direct"
|
|
||||||
)
|
|
||||||
self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="direct"
|
|
||||||
)
|
|
||||||
obj.mention_users.add(self.local_user)
|
|
||||||
self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
def test_object_visible_to_user_blocked(self, _):
|
|
||||||
""" you can't see it if they block you """
|
|
||||||
self.remote_user.blocks.add(self.local_user)
|
|
||||||
obj = models.Status.objects.create(
|
|
||||||
content="hi", user=self.remote_user, privacy="public"
|
|
||||||
)
|
|
||||||
self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
obj = models.Shelf.objects.create(
|
|
||||||
name="test", user=self.remote_user, privacy="unlisted"
|
|
||||||
)
|
|
||||||
self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj))
|
|
||||||
|
|
||||||
def test_get_annotated_users(self, _):
|
def test_get_annotated_users(self, _):
|
||||||
""" list of people you might know """
|
""" list of people you might know """
|
||||||
user_1 = models.User.objects.create_user(
|
user_1 = models.User.objects.create_user(
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" test for app action functionality """
|
""" test for app action functionality """
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
@ -71,16 +72,6 @@ class ListViews(TestCase):
|
||||||
|
|
||||||
def test_lists_create(self):
|
def test_lists_create(self):
|
||||||
""" create list view """
|
""" create list view """
|
||||||
real_broadcast = models.List.broadcast
|
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user, **kwargs):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Create")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
|
|
||||||
models.List.broadcast = mock_broadcast
|
|
||||||
|
|
||||||
view = views.Lists.as_view()
|
view = views.Lists.as_view()
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"",
|
"",
|
||||||
|
@ -93,13 +84,19 @@ class ListViews(TestCase):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
result = view(request)
|
result = view(request)
|
||||||
|
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Create")
|
||||||
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 302)
|
self.assertEqual(result.status_code, 302)
|
||||||
new_list = models.List.objects.filter(name="A list").get()
|
new_list = models.List.objects.filter(name="A list").get()
|
||||||
self.assertEqual(new_list.description, "wow")
|
self.assertEqual(new_list.description, "wow")
|
||||||
self.assertEqual(new_list.privacy, "unlisted")
|
self.assertEqual(new_list.privacy, "unlisted")
|
||||||
self.assertEqual(new_list.curation, "open")
|
self.assertEqual(new_list.curation, "open")
|
||||||
models.List.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_list_page(self):
|
def test_list_page(self):
|
||||||
""" there are so many views, this just makes sure it LOADS """
|
""" there are so many views, this just makes sure it LOADS """
|
||||||
|
@ -138,17 +135,6 @@ class ListViews(TestCase):
|
||||||
|
|
||||||
def test_list_edit(self):
|
def test_list_edit(self):
|
||||||
""" edit a list """
|
""" edit a list """
|
||||||
real_broadcast = models.List.broadcast
|
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user, **kwargs):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Update")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["object"]["id"], self.list.remote_id)
|
|
||||||
|
|
||||||
models.List.broadcast = mock_broadcast
|
|
||||||
|
|
||||||
view = views.List.as_view()
|
view = views.List.as_view()
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"",
|
"",
|
||||||
|
@ -162,7 +148,15 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
result = view(request, self.list.id)
|
result = view(request, self.list.id)
|
||||||
|
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Update")
|
||||||
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
|
self.assertEqual(activity["object"]["id"], self.list.remote_id)
|
||||||
|
|
||||||
self.assertEqual(result.status_code, 302)
|
self.assertEqual(result.status_code, 302)
|
||||||
|
|
||||||
self.list.refresh_from_db()
|
self.list.refresh_from_db()
|
||||||
|
@ -170,7 +164,6 @@ class ListViews(TestCase):
|
||||||
self.assertEqual(self.list.description, "wow")
|
self.assertEqual(self.list.description, "wow")
|
||||||
self.assertEqual(self.list.privacy, "direct")
|
self.assertEqual(self.list.privacy, "direct")
|
||||||
self.assertEqual(self.list.curation, "curated")
|
self.assertEqual(self.list.curation, "curated")
|
||||||
models.List.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_curate_page(self):
|
def test_curate_page(self):
|
||||||
""" there are so many views, this just makes sure it LOADS """
|
""" there are so many views, this just makes sure it LOADS """
|
||||||
|
@ -194,17 +187,6 @@ class ListViews(TestCase):
|
||||||
|
|
||||||
def test_curate_approve(self):
|
def test_curate_approve(self):
|
||||||
""" approve a pending item """
|
""" approve a pending item """
|
||||||
real_broadcast = models.List.broadcast
|
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user, **kwargs):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Add")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["target"], self.list.remote_id)
|
|
||||||
|
|
||||||
models.ListItem.broadcast = mock_broadcast
|
|
||||||
|
|
||||||
view = views.Curate.as_view()
|
view = views.Curate.as_view()
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
pending = models.ListItem.objects.create(
|
pending = models.ListItem.objects.create(
|
||||||
|
@ -223,12 +205,19 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
view(request, self.list.id)
|
view(request, self.list.id)
|
||||||
|
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Add")
|
||||||
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
|
self.assertEqual(activity["target"], self.list.remote_id)
|
||||||
|
|
||||||
pending.refresh_from_db()
|
pending.refresh_from_db()
|
||||||
self.assertEqual(self.list.books.count(), 1)
|
self.assertEqual(self.list.books.count(), 1)
|
||||||
self.assertEqual(self.list.listitem_set.first(), pending)
|
self.assertEqual(self.list.listitem_set.first(), pending)
|
||||||
self.assertTrue(pending.approved)
|
self.assertTrue(pending.approved)
|
||||||
models.ListItem.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_curate_reject(self):
|
def test_curate_reject(self):
|
||||||
""" approve a pending item """
|
""" approve a pending item """
|
||||||
|
@ -250,23 +239,13 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
|
||||||
view(request, self.list.id)
|
view(request, self.list.id)
|
||||||
|
|
||||||
self.assertFalse(self.list.books.exists())
|
self.assertFalse(self.list.books.exists())
|
||||||
self.assertFalse(models.ListItem.objects.exists())
|
self.assertFalse(models.ListItem.objects.exists())
|
||||||
|
|
||||||
def test_add_book(self):
|
def test_add_book(self):
|
||||||
""" put a book on a list """
|
""" put a book on a list """
|
||||||
real_broadcast = models.List.broadcast
|
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Add")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["target"], self.list.remote_id)
|
|
||||||
|
|
||||||
models.ListItem.broadcast = mock_broadcast
|
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"",
|
"",
|
||||||
{
|
{
|
||||||
|
@ -276,25 +255,21 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
views.list.add_book(request)
|
views.list.add_book(request)
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Add")
|
||||||
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
|
self.assertEqual(activity["target"], self.list.remote_id)
|
||||||
|
|
||||||
item = self.list.listitem_set.get()
|
item = self.list.listitem_set.get()
|
||||||
self.assertEqual(item.book, self.book)
|
self.assertEqual(item.book, self.book)
|
||||||
self.assertEqual(item.user, self.local_user)
|
self.assertEqual(item.user, self.local_user)
|
||||||
self.assertTrue(item.approved)
|
self.assertTrue(item.approved)
|
||||||
models.ListItem.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_add_book_outsider(self):
|
def test_add_book_outsider(self):
|
||||||
""" put a book on a list """
|
""" put a book on a list """
|
||||||
real_broadcast = models.List.broadcast
|
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.rat.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Add")
|
|
||||||
self.assertEqual(activity["actor"], self.rat.remote_id)
|
|
||||||
self.assertEqual(activity["target"], self.list.remote_id)
|
|
||||||
|
|
||||||
models.ListItem.broadcast = mock_broadcast
|
|
||||||
self.list.curation = "open"
|
self.list.curation = "open"
|
||||||
self.list.save(broadcast=False)
|
self.list.save(broadcast=False)
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
|
@ -306,26 +281,21 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.rat
|
request.user = self.rat
|
||||||
|
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
views.list.add_book(request)
|
views.list.add_book(request)
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Add")
|
||||||
|
self.assertEqual(activity["actor"], self.rat.remote_id)
|
||||||
|
self.assertEqual(activity["target"], self.list.remote_id)
|
||||||
|
|
||||||
item = self.list.listitem_set.get()
|
item = self.list.listitem_set.get()
|
||||||
self.assertEqual(item.book, self.book)
|
self.assertEqual(item.book, self.book)
|
||||||
self.assertEqual(item.user, self.rat)
|
self.assertEqual(item.user, self.rat)
|
||||||
self.assertTrue(item.approved)
|
self.assertTrue(item.approved)
|
||||||
models.ListItem.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_add_book_pending(self):
|
def test_add_book_pending(self):
|
||||||
""" put a book on a list awaiting approval """
|
""" put a book on a list awaiting approval """
|
||||||
real_broadcast = models.List.broadcast
|
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.rat.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Add")
|
|
||||||
self.assertEqual(activity["actor"], self.rat.remote_id)
|
|
||||||
self.assertEqual(activity["target"], self.list.remote_id)
|
|
||||||
self.assertEqual(activity["object"]["id"], self.book.remote_id)
|
|
||||||
|
|
||||||
models.ListItem.broadcast = mock_broadcast
|
|
||||||
self.list.curation = "curated"
|
self.list.curation = "curated"
|
||||||
self.list.save(broadcast=False)
|
self.list.save(broadcast=False)
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
|
@ -337,26 +307,25 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.rat
|
request.user = self.rat
|
||||||
|
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
views.list.add_book(request)
|
views.list.add_book(request)
|
||||||
|
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
|
||||||
|
self.assertEqual(activity["type"], "Add")
|
||||||
|
self.assertEqual(activity["actor"], self.rat.remote_id)
|
||||||
|
self.assertEqual(activity["target"], self.list.remote_id)
|
||||||
|
|
||||||
item = self.list.listitem_set.get()
|
item = self.list.listitem_set.get()
|
||||||
|
self.assertEqual(activity["object"]["id"], item.remote_id)
|
||||||
|
|
||||||
self.assertEqual(item.book, self.book)
|
self.assertEqual(item.book, self.book)
|
||||||
self.assertEqual(item.user, self.rat)
|
self.assertEqual(item.user, self.rat)
|
||||||
self.assertFalse(item.approved)
|
self.assertFalse(item.approved)
|
||||||
models.ListItem.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_add_book_self_curated(self):
|
def test_add_book_self_curated(self):
|
||||||
""" put a book on a list automatically approved """
|
""" put a book on a list automatically approved """
|
||||||
real_broadcast = models.ListItem.broadcast
|
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Add")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["target"], self.list.remote_id)
|
|
||||||
|
|
||||||
models.ListItem.broadcast = mock_broadcast
|
|
||||||
|
|
||||||
self.list.curation = "curated"
|
self.list.curation = "curated"
|
||||||
self.list.save(broadcast=False)
|
self.list.save(broadcast=False)
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
|
@ -368,16 +337,21 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
views.list.add_book(request)
|
views.list.add_book(request)
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Add")
|
||||||
|
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
||||||
|
self.assertEqual(activity["target"], self.list.remote_id)
|
||||||
|
|
||||||
item = self.list.listitem_set.get()
|
item = self.list.listitem_set.get()
|
||||||
self.assertEqual(item.book, self.book)
|
self.assertEqual(item.book, self.book)
|
||||||
self.assertEqual(item.user, self.local_user)
|
self.assertEqual(item.user, self.local_user)
|
||||||
self.assertTrue(item.approved)
|
self.assertTrue(item.approved)
|
||||||
models.ListItem.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_remove_book(self):
|
def test_remove_book(self):
|
||||||
""" take an item off a list """
|
""" take an item off a list """
|
||||||
real_broadcast = models.ListItem.broadcast
|
|
||||||
|
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
item = models.ListItem.objects.create(
|
item = models.ListItem.objects.create(
|
||||||
|
@ -387,14 +361,6 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
self.assertTrue(self.list.listitem_set.exists())
|
self.assertTrue(self.list.listitem_set.exists())
|
||||||
|
|
||||||
def mock_broadcast(_, activity, user):
|
|
||||||
""" ok """
|
|
||||||
self.assertEqual(user.remote_id, self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["type"], "Remove")
|
|
||||||
self.assertEqual(activity["actor"], self.local_user.remote_id)
|
|
||||||
self.assertEqual(activity["target"], self.list.remote_id)
|
|
||||||
|
|
||||||
models.ListItem.broadcast = mock_broadcast
|
|
||||||
request = self.factory.post(
|
request = self.factory.post(
|
||||||
"",
|
"",
|
||||||
{
|
{
|
||||||
|
@ -403,10 +369,9 @@ class ListViews(TestCase):
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
|
|
||||||
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||||
views.list.remove_book(request, self.list.id)
|
views.list.remove_book(request, self.list.id)
|
||||||
|
|
||||||
self.assertFalse(self.list.listitem_set.exists())
|
self.assertFalse(self.list.listitem_set.exists())
|
||||||
models.ListItem.broadcast = real_broadcast
|
|
||||||
|
|
||||||
def test_remove_book_unauthorized(self):
|
def test_remove_book_unauthorized(self):
|
||||||
""" take an item off a list """
|
""" take an item off a list """
|
||||||
|
@ -426,5 +391,4 @@ class ListViews(TestCase):
|
||||||
request.user = self.rat
|
request.user = self.rat
|
||||||
|
|
||||||
views.list.remove_book(request, self.list.id)
|
views.list.remove_book(request, self.list.id)
|
||||||
|
|
||||||
self.assertTrue(self.list.listitem_set.exists())
|
self.assertTrue(self.list.listitem_set.exists())
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" test for app action functionality """
|
""" test for app action functionality """
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
@ -120,8 +121,15 @@ class ShelfViews(TestCase):
|
||||||
"", {"book": self.book.id, "shelf": self.shelf.identifier}
|
"", {"book": self.book.id, "shelf": self.shelf.identifier}
|
||||||
)
|
)
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
views.shelve(request)
|
views.shelve(request)
|
||||||
|
|
||||||
|
self.assertEqual(mock.call_count, 1)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Add")
|
||||||
|
|
||||||
|
item = models.ShelfBook.objects.get()
|
||||||
|
self.assertEqual(activity["object"]["id"], item.remote_id)
|
||||||
# make sure the book is on the shelf
|
# make sure the book is on the shelf
|
||||||
self.assertEqual(self.shelf.books.get(), self.book)
|
self.assertEqual(self.shelf.books.get(), self.book)
|
||||||
|
|
||||||
|
@ -170,10 +178,15 @@ class ShelfViews(TestCase):
|
||||||
models.ShelfBook.objects.create(
|
models.ShelfBook.objects.create(
|
||||||
book=self.book, user=self.local_user, shelf=self.shelf
|
book=self.book, user=self.local_user, shelf=self.shelf
|
||||||
)
|
)
|
||||||
|
item = models.ShelfBook.objects.get()
|
||||||
|
|
||||||
self.shelf.save()
|
self.shelf.save()
|
||||||
self.assertEqual(self.shelf.books.count(), 1)
|
self.assertEqual(self.shelf.books.count(), 1)
|
||||||
request = self.factory.post("", {"book": self.book.id, "shelf": self.shelf.id})
|
request = self.factory.post("", {"book": self.book.id, "shelf": self.shelf.id})
|
||||||
request.user = self.local_user
|
request.user = self.local_user
|
||||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
|
||||||
views.unshelve(request)
|
views.unshelve(request)
|
||||||
|
activity = json.loads(mock.call_args[0][1])
|
||||||
|
self.assertEqual(activity["type"], "Remove")
|
||||||
|
self.assertEqual(activity["object"]["id"], item.remote_id)
|
||||||
self.assertEqual(self.shelf.books.count(), 0)
|
self.assertEqual(self.shelf.books.count(), 0)
|
||||||
|
|
|
@ -68,6 +68,26 @@ urlpatterns = [
|
||||||
views.FederatedServer.as_view(),
|
views.FederatedServer.as_view(),
|
||||||
name="settings-federated-server",
|
name="settings-federated-server",
|
||||||
),
|
),
|
||||||
|
re_path(
|
||||||
|
r"^settings/federation/(?P<server>\d+)/block?$",
|
||||||
|
views.federation.block_server,
|
||||||
|
name="settings-federated-server-block",
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
r"^settings/federation/(?P<server>\d+)/unblock?$",
|
||||||
|
views.federation.unblock_server,
|
||||||
|
name="settings-federated-server-unblock",
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
r"^settings/federation/add/?$",
|
||||||
|
views.AddFederatedServer.as_view(),
|
||||||
|
name="settings-add-federated-server",
|
||||||
|
),
|
||||||
|
re_path(
|
||||||
|
r"^settings/federation/import/?$",
|
||||||
|
views.ImportServerBlocklist.as_view(),
|
||||||
|
name="settings-import-blocklist",
|
||||||
|
),
|
||||||
re_path(
|
re_path(
|
||||||
r"^settings/invites/?$", views.ManageInvites.as_view(), name="settings-invites"
|
r"^settings/invites/?$", views.ManageInvites.as_view(), name="settings-invites"
|
||||||
),
|
),
|
||||||
|
|
|
@ -6,6 +6,8 @@ from .books import Book, EditBook, ConfirmEditBook, Editions
|
||||||
from .books import upload_cover, add_description, switch_edition, resolve_book
|
from .books import upload_cover, add_description, switch_edition, resolve_book
|
||||||
from .directory import Directory
|
from .directory import Directory
|
||||||
from .federation import Federation, FederatedServer
|
from .federation import Federation, FederatedServer
|
||||||
|
from .federation import AddFederatedServer, ImportServerBlocklist
|
||||||
|
from .federation import block_server, unblock_server
|
||||||
from .feed import DirectMessage, Feed, Replies, Status
|
from .feed import DirectMessage, Feed, Replies, Status
|
||||||
from .follow import follow, unfollow
|
from .follow import follow, unfollow
|
||||||
from .follow import accept_follow_request, delete_follow_request
|
from .follow import accept_follow_request, delete_follow_request
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
""" manage federated servers """
|
""" manage federated servers """
|
||||||
|
import json
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.shortcuts import get_object_or_404
|
from django.db import transaction
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import forms, models
|
||||||
from bookwyrm.settings import PAGE_LENGTH
|
from bookwyrm.settings import PAGE_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,14 +33,74 @@ class Federation(View):
|
||||||
|
|
||||||
sort = request.GET.get("sort")
|
sort = request.GET.get("sort")
|
||||||
sort_fields = ["created_date", "application_type", "server_name"]
|
sort_fields = ["created_date", "application_type", "server_name"]
|
||||||
if sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
|
if not sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]:
|
||||||
|
sort = "created_date"
|
||||||
servers = servers.order_by(sort)
|
servers = servers.order_by(sort)
|
||||||
|
|
||||||
paginated = Paginator(servers, PAGE_LENGTH)
|
paginated = Paginator(servers, PAGE_LENGTH)
|
||||||
data = {"servers": paginated.page(page), "sort": sort}
|
|
||||||
|
data = {
|
||||||
|
"servers": paginated.page(page),
|
||||||
|
"sort": sort,
|
||||||
|
"form": forms.ServerForm(),
|
||||||
|
}
|
||||||
return TemplateResponse(request, "settings/federation.html", data)
|
return TemplateResponse(request, "settings/federation.html", data)
|
||||||
|
|
||||||
|
|
||||||
|
class AddFederatedServer(View):
|
||||||
|
""" manually add a server """
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
""" add server form """
|
||||||
|
data = {"form": forms.ServerForm()}
|
||||||
|
return TemplateResponse(request, "settings/edit_server.html", data)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
""" add a server from the admin panel """
|
||||||
|
form = forms.ServerForm(request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
data = {"form": form}
|
||||||
|
return TemplateResponse(request, "settings/edit_server.html", data)
|
||||||
|
server = form.save()
|
||||||
|
return redirect("settings-federated-server", server.id)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name="dispatch")
|
||||||
|
@method_decorator(
|
||||||
|
permission_required("bookwyrm.control_federation", raise_exception=True),
|
||||||
|
name="dispatch",
|
||||||
|
)
|
||||||
|
class ImportServerBlocklist(View):
|
||||||
|
""" manually add a server """
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
""" add server form """
|
||||||
|
return TemplateResponse(request, "settings/server_blocklist.html")
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
""" add a server from the admin panel """
|
||||||
|
json_data = json.load(request.FILES["json_file"])
|
||||||
|
failed = []
|
||||||
|
success_count = 0
|
||||||
|
for item in json_data:
|
||||||
|
server_name = item.get("instance")
|
||||||
|
if not server_name:
|
||||||
|
failed.append(item)
|
||||||
|
continue
|
||||||
|
info_link = item.get("url")
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
server, _ = models.FederatedServer.objects.get_or_create(
|
||||||
|
server_name=server_name,
|
||||||
|
)
|
||||||
|
server.notes = info_link
|
||||||
|
server.save()
|
||||||
|
server.block()
|
||||||
|
success_count += 1
|
||||||
|
data = {"failed": failed, "succeeded": success_count}
|
||||||
|
return TemplateResponse(request, "settings/server_blocklist.html", data)
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(login_required, name="dispatch")
|
@method_decorator(login_required, name="dispatch")
|
||||||
@method_decorator(
|
@method_decorator(
|
||||||
permission_required("bookwyrm.control_federation", raise_exception=True),
|
permission_required("bookwyrm.control_federation", raise_exception=True),
|
||||||
|
@ -61,3 +124,32 @@ class FederatedServer(View):
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
return TemplateResponse(request, "settings/federated_server.html", data)
|
return TemplateResponse(request, "settings/federated_server.html", data)
|
||||||
|
|
||||||
|
def post(self, request, server): # pylint: disable=unused-argument
|
||||||
|
""" update note """
|
||||||
|
server = get_object_or_404(models.FederatedServer, id=server)
|
||||||
|
server.notes = request.POST.get("notes")
|
||||||
|
server.save()
|
||||||
|
return redirect("settings-federated-server", server.id)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
@permission_required("bookwyrm.control_federation", raise_exception=True)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def block_server(request, server):
|
||||||
|
""" block a server """
|
||||||
|
server = get_object_or_404(models.FederatedServer, id=server)
|
||||||
|
server.block()
|
||||||
|
return redirect("settings-federated-server", server.id)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
@permission_required("bookwyrm.control_federation", raise_exception=True)
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def unblock_server(request, server):
|
||||||
|
""" unblock a server """
|
||||||
|
server = get_object_or_404(models.FederatedServer, id=server)
|
||||||
|
server.unblock()
|
||||||
|
return redirect("settings-federated-server", server.id)
|
||||||
|
|
|
@ -12,7 +12,7 @@ from bookwyrm import activitystreams, forms, models
|
||||||
from bookwyrm.activitypub import ActivitypubResponse
|
from bookwyrm.activitypub import ActivitypubResponse
|
||||||
from bookwyrm.settings import PAGE_LENGTH, STREAMS
|
from bookwyrm.settings import PAGE_LENGTH, STREAMS
|
||||||
from .helpers import get_user_from_username, privacy_filter, get_suggested_users
|
from .helpers import get_user_from_username, privacy_filter, get_suggested_users
|
||||||
from .helpers import is_api_request, is_bookwyrm_request, object_visible_to_user
|
from .helpers import is_api_request, is_bookwyrm_request
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable= no-self-use
|
# pylint: disable= no-self-use
|
||||||
|
@ -113,7 +113,7 @@ class Status(View):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
# make sure the user is authorized to see the status
|
# make sure the user is authorized to see the status
|
||||||
if not object_visible_to_user(request.user, status):
|
if not status.visible_to_user(request.user):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
|
|
|
@ -10,7 +10,7 @@ from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from bookwyrm import forms, models
|
from bookwyrm import forms, models
|
||||||
from bookwyrm.status import create_generated_note
|
from bookwyrm.status import create_generated_note
|
||||||
from .helpers import get_user_from_username, object_visible_to_user
|
from .helpers import get_user_from_username
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable= no-self-use
|
# pylint: disable= no-self-use
|
||||||
|
@ -26,7 +26,7 @@ class Goal(View):
|
||||||
if not goal and user != request.user:
|
if not goal and user != request.user:
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if goal and not object_visible_to_user(request.user, goal):
|
if goal and not goal.visible_to_user(request.user):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
|
|
|
@ -32,30 +32,6 @@ def is_bookwyrm_request(request):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def object_visible_to_user(viewer, obj):
|
|
||||||
""" is a user authorized to view an object? """
|
|
||||||
if not obj:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# viewer can't see it if the object's owner blocked them
|
|
||||||
if viewer in obj.user.blocks.all():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# you can see your own posts and any public or unlisted posts
|
|
||||||
if viewer == obj.user or obj.privacy in ["public", "unlisted"]:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# you can see the followers only posts of people you follow
|
|
||||||
if obj.privacy == "followers" and obj.user.followers.filter(id=viewer.id).first():
|
|
||||||
return True
|
|
||||||
|
|
||||||
# you can see dms you are tagged in
|
|
||||||
if isinstance(obj, models.Status):
|
|
||||||
if obj.privacy == "direct" and obj.mention_users.filter(id=viewer.id).first():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def privacy_filter(viewer, queryset, privacy_levels=None, following_only=False):
|
def privacy_filter(viewer, queryset, privacy_levels=None, following_only=False):
|
||||||
""" filter objects that have "user" and "privacy" fields """
|
""" filter objects that have "user" and "privacy" fields """
|
||||||
privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"]
|
privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"]
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
""" incoming activities """
|
""" incoming activities """
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from urllib.parse import urldefrag
|
from urllib.parse import urldefrag
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, HttpResponseNotFound
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound
|
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
@ -12,6 +13,7 @@ import requests
|
||||||
from bookwyrm import activitypub, models
|
from bookwyrm import activitypub, models
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app
|
||||||
from bookwyrm.signatures import Signature
|
from bookwyrm.signatures import Signature
|
||||||
|
from bookwyrm.utils import regex
|
||||||
|
|
||||||
|
|
||||||
@method_decorator(csrf_exempt, name="dispatch")
|
@method_decorator(csrf_exempt, name="dispatch")
|
||||||
|
@ -21,6 +23,10 @@ class Inbox(View):
|
||||||
|
|
||||||
def post(self, request, username=None):
|
def post(self, request, username=None):
|
||||||
""" only works as POST request """
|
""" only works as POST request """
|
||||||
|
# first check if this server is on our shitlist
|
||||||
|
if is_blocked_user_agent(request):
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
# make sure the user's inbox even exists
|
# make sure the user's inbox even exists
|
||||||
if username:
|
if username:
|
||||||
try:
|
try:
|
||||||
|
@ -34,6 +40,10 @@ class Inbox(View):
|
||||||
except json.decoder.JSONDecodeError:
|
except json.decoder.JSONDecodeError:
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
# let's be extra sure we didn't block this domain
|
||||||
|
if is_blocked_activity(activity_json):
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not "object" in activity_json
|
not "object" in activity_json
|
||||||
or not "type" in activity_json
|
or not "type" in activity_json
|
||||||
|
@ -54,22 +64,34 @@ class Inbox(View):
|
||||||
return HttpResponse()
|
return HttpResponse()
|
||||||
|
|
||||||
|
|
||||||
|
def is_blocked_user_agent(request):
|
||||||
|
""" check if a request is from a blocked server based on user agent """
|
||||||
|
# check user agent
|
||||||
|
user_agent = request.headers.get("User-Agent")
|
||||||
|
if not user_agent:
|
||||||
|
return False
|
||||||
|
url = re.search(r"https?://{:s}/?".format(regex.domain), user_agent).group()
|
||||||
|
return models.FederatedServer.is_blocked(url)
|
||||||
|
|
||||||
|
|
||||||
|
def is_blocked_activity(activity_json):
|
||||||
|
""" get the sender out of activity json and check if it's blocked """
|
||||||
|
actor = activity_json.get("actor")
|
||||||
|
if not actor:
|
||||||
|
# well I guess it's not even a valid activity so who knows
|
||||||
|
return False
|
||||||
|
return models.FederatedServer.is_blocked(actor)
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def activity_task(activity_json):
|
def activity_task(activity_json):
|
||||||
""" do something with this json we think is legit """
|
""" do something with this json we think is legit """
|
||||||
# lets see if the activitypub module can make sense of this json
|
# lets see if the activitypub module can make sense of this json
|
||||||
try:
|
|
||||||
activity = activitypub.parse(activity_json)
|
activity = activitypub.parse(activity_json)
|
||||||
except activitypub.ActivitySerializerError:
|
|
||||||
return
|
|
||||||
|
|
||||||
# cool that worked, now we should do the action described by the type
|
# cool that worked, now we should do the action described by the type
|
||||||
# (create, update, delete, etc)
|
# (create, update, delete, etc)
|
||||||
try:
|
|
||||||
activity.action()
|
activity.action()
|
||||||
except activitypub.ActivitySerializerError:
|
|
||||||
# this is raised if the activity is discarded
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def has_valid_signature(request, activity):
|
def has_valid_signature(request, activity):
|
||||||
|
|
|
@ -13,7 +13,7 @@ from django.views.decorators.http import require_POST
|
||||||
from bookwyrm import forms, models
|
from bookwyrm import forms, models
|
||||||
from bookwyrm.activitypub import ActivitypubResponse
|
from bookwyrm.activitypub import ActivitypubResponse
|
||||||
from bookwyrm.connectors import connector_manager
|
from bookwyrm.connectors import connector_manager
|
||||||
from .helpers import is_api_request, object_visible_to_user, privacy_filter
|
from .helpers import is_api_request, privacy_filter
|
||||||
from .helpers import get_user_from_username
|
from .helpers import get_user_from_username
|
||||||
|
|
||||||
# pylint: disable=no-self-use
|
# pylint: disable=no-self-use
|
||||||
|
@ -92,7 +92,7 @@ class List(View):
|
||||||
def get(self, request, list_id):
|
def get(self, request, list_id):
|
||||||
""" display a book list """
|
""" display a book list """
|
||||||
book_list = get_object_or_404(models.List, id=list_id)
|
book_list = get_object_or_404(models.List, id=list_id)
|
||||||
if not object_visible_to_user(request.user, book_list):
|
if not book_list.visible_to_user(request.user):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
|
@ -168,7 +168,7 @@ class Curate(View):
|
||||||
suggestion.approved = True
|
suggestion.approved = True
|
||||||
suggestion.save()
|
suggestion.save()
|
||||||
else:
|
else:
|
||||||
suggestion.delete()
|
suggestion.delete(broadcast=False)
|
||||||
return redirect("list-curate", book_list.id)
|
return redirect("list-curate", book_list.id)
|
||||||
|
|
||||||
|
|
||||||
|
@ -176,7 +176,7 @@ class Curate(View):
|
||||||
def add_book(request):
|
def add_book(request):
|
||||||
""" put a book on a list """
|
""" put a book on a list """
|
||||||
book_list = get_object_or_404(models.List, id=request.POST.get("list"))
|
book_list = get_object_or_404(models.List, id=request.POST.get("list"))
|
||||||
if not object_visible_to_user(request.user, book_list):
|
if not book_list.visible_to_user(request.user):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
|
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
|
||||||
|
|
|
@ -16,7 +16,7 @@ from bookwyrm import forms, models
|
||||||
from bookwyrm.activitypub import ActivitypubResponse
|
from bookwyrm.activitypub import ActivitypubResponse
|
||||||
from bookwyrm.settings import PAGE_LENGTH
|
from bookwyrm.settings import PAGE_LENGTH
|
||||||
from .helpers import is_api_request, get_edition, get_user_from_username
|
from .helpers import is_api_request, get_edition, get_user_from_username
|
||||||
from .helpers import handle_reading_status, privacy_filter, object_visible_to_user
|
from .helpers import handle_reading_status, privacy_filter
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable= no-self-use
|
# pylint: disable= no-self-use
|
||||||
|
@ -43,7 +43,7 @@ class Shelf(View):
|
||||||
shelf = user.shelf_set.get(identifier=shelf_identifier)
|
shelf = user.shelf_set.get(identifier=shelf_identifier)
|
||||||
except models.Shelf.DoesNotExist:
|
except models.Shelf.DoesNotExist:
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
if not object_visible_to_user(request.user, shelf):
|
if not shelf.visible_to_user(request.user):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
# this is a constructed "all books" view, with a fake "shelf" obj
|
# this is a constructed "all books" view, with a fake "shelf" obj
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -17,7 +17,7 @@ from bookwyrm import forms, models
|
||||||
from bookwyrm.activitypub import ActivitypubResponse
|
from bookwyrm.activitypub import ActivitypubResponse
|
||||||
from bookwyrm.settings import PAGE_LENGTH
|
from bookwyrm.settings import PAGE_LENGTH
|
||||||
from .helpers import get_user_from_username, is_api_request
|
from .helpers import get_user_from_username, is_api_request
|
||||||
from .helpers import is_blocked, privacy_filter, object_visible_to_user
|
from .helpers import is_blocked, privacy_filter
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable= no-self-use
|
# pylint: disable= no-self-use
|
||||||
|
@ -80,7 +80,7 @@ class User(View):
|
||||||
goal = models.AnnualGoal.objects.filter(
|
goal = models.AnnualGoal.objects.filter(
|
||||||
user=user, year=timezone.now().year
|
user=user, year=timezone.now().year
|
||||||
).first()
|
).first()
|
||||||
if not object_visible_to_user(request.user, goal):
|
if goal and not goal.visible_to_user(request.user):
|
||||||
goal = None
|
goal = None
|
||||||
data = {
|
data = {
|
||||||
"user": user,
|
"user": user,
|
||||||
|
|
|
@ -20,7 +20,8 @@ EMAIL_HOST = env("EMAIL_HOST")
|
||||||
EMAIL_PORT = env("EMAIL_PORT")
|
EMAIL_PORT = env("EMAIL_PORT")
|
||||||
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")
|
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS")
|
||||||
|
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
|
||||||
|
|
||||||
|
|
||||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||||
|
@ -148,7 +149,7 @@ USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/3.0/howto/static-files/
|
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
|
||||||
|
|
19
certbot.sh
Normal file
19
certbot.sh
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
source .env;
|
||||||
|
|
||||||
|
if [ "$CERTBOT_INIT" = "true" ]
|
||||||
|
then
|
||||||
|
certonly \
|
||||||
|
--webroot \
|
||||||
|
--webroot-path=/var/www/certbot \
|
||||||
|
--email ${EMAIL} \
|
||||||
|
--agree-tos \
|
||||||
|
--no-eff-email \
|
||||||
|
-d ${DOMAIN} \
|
||||||
|
-d www.${DOMAIN}
|
||||||
|
else
|
||||||
|
renew \
|
||||||
|
--webroot \
|
||||||
|
--webroot-path \
|
||||||
|
/var/www/certbot
|
||||||
|
fi
|
|
@ -18,8 +18,7 @@ services:
|
||||||
- media_volume:/app/images
|
- media_volume:/app/images
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot:latest
|
image: certbot/certbot:latest
|
||||||
command: certonly --webroot --webroot-path=/var/www/certbot --email ${EMAIL} --agree-tos --no-eff-email -d ${DOMAIN} -d www.${DOMAIN}
|
command: bash ./certbot.sh
|
||||||
#command: renew --webroot --webroot-path /var/www/certbot
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
- ./certbot/logs:/var/log/letsencrypt
|
- ./certbot/logs:/var/log/letsencrypt
|
||||||
|
@ -34,6 +33,8 @@ services:
|
||||||
- backups:/backups
|
- backups:/backups
|
||||||
networks:
|
networks:
|
||||||
- main
|
- main
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
|
|
||||||
| name | url | admin contact | open registration |
|
|
||||||
| :--- | :-- | :------------ | :---------------- |
|
|
||||||
| bookwyrm.social | http://bookwyrm.social/ | mousereeve@riseup.net / @tripofmice@friend.camp | ❌ |
|
|
||||||
| wyrms.de | https://wyrms.de/ | wyrms@tofuwabo.hu / @tofuwabohu@subversive.zone | ❌ |
|
|
File diff suppressed because it is too large
Load diff
72
nginx/production
Normal file
72
nginx/production
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
upstream web {
|
||||||
|
server web:8000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen [::]:80;
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
location ~ /.well-known/acme-challenge {
|
||||||
|
allow all;
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
# # redirect http to https
|
||||||
|
# return 301 https://your-domain.com$request_uri;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# server {
|
||||||
|
# listen [::]:443 ssl http2;
|
||||||
|
# listen 443 ssl http2;
|
||||||
|
#
|
||||||
|
# server_name your-domain.com;
|
||||||
|
#
|
||||||
|
# # SSL code
|
||||||
|
# ssl_certificate /etc/nginx/ssl/live/your-domain.com/fullchain.pem;
|
||||||
|
# ssl_certificate_key /etc/nginx/ssl/live/your-domain.com/privkey.pem;
|
||||||
|
#
|
||||||
|
# location ~ /.well-known/acme-challenge {
|
||||||
|
# allow all;
|
||||||
|
# root /var/www/certbot;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://web;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_redirect off;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /images/ {
|
||||||
|
# alias /app/images/;
|
||||||
|
# }
|
||||||
|
#
|
||||||
|
# location /static/ {
|
||||||
|
# alias /app/static/;
|
||||||
|
# }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse-Proxy server
|
||||||
|
# server {
|
||||||
|
# listen [::]:8001;
|
||||||
|
# listen 8001;
|
||||||
|
|
||||||
|
# server_name your-domain.com www.your-domain.com;
|
||||||
|
|
||||||
|
# location / {
|
||||||
|
# proxy_pass http://web;
|
||||||
|
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
# proxy_set_header Host $host;
|
||||||
|
# proxy_redirect off;
|
||||||
|
# }
|
||||||
|
|
||||||
|
# location /images/ {
|
||||||
|
# alias /app/images/;
|
||||||
|
# }
|
||||||
|
|
||||||
|
# location /static/ {
|
||||||
|
# alias /app/static/;
|
||||||
|
# }
|
||||||
|
# }
|
|
@ -1,8 +1,12 @@
|
||||||
{
|
{
|
||||||
|
"scripts": {
|
||||||
|
"watch:static": "yarn watch \"./bw-dev collectstatic\" bookwyrm/static/**"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^7.23.0",
|
"eslint": "^7.23.0",
|
||||||
"stylelint": "^13.12.0",
|
"stylelint": "^13.12.0",
|
||||||
"stylelint-config-standard": "^21.0.0",
|
"stylelint-config-standard": "^21.0.0",
|
||||||
"stylelint-order": "^4.1.0"
|
"stylelint-order": "^4.1.0",
|
||||||
|
"watch": "^1.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue