diff --git a/.coveragerc b/.coveragerc
index 35bf78f5..08406450 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,2 +1,2 @@
[run]
-omit = */test*,celerywyrm*,bookwyrm/migrations/*
\ No newline at end of file
+omit = */test*,celerywyrm*,bookwyrm/migrations/*
diff --git a/.dockerignore b/.dockerignore
index 3bf9f2c5..a5130c8b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -4,4 +4,4 @@ __pycache__
*.pyd
.git
.github
-.pytest*
\ No newline at end of file
+.pytest*
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..d102bc5a
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,39 @@
+# @see https://editorconfig.org/
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+charset = utf-8
+end_of_line = lf
+indent_size = 4
+indent_style = space
+insert_final_newline = true
+max_line_length = 100
+
+# C-style doc comments
+block_comment_start = /*
+block_comment = *
+block_comment_end = */
+
+[{bw-dev,fr-dev,LICENSE}]
+max_line_length = off
+
+[*.{csv,json,html,md,po,py,svg,tsv}]
+max_line_length = off
+
+# ` ` at the end of a line is a line-break in markdown
+[*.{md,markdown}]
+trim_trailing_whitespace = false
+
+[*.{yml,yaml}]
+indent_size = 2
+max_line_length = off
+
+# Computer generated files
+[{package.json,*.lock,*.mo}]
+indent_size = unset
+indent_style = unset
+max_line_length = unset
+insert_final_newline = unset
diff --git a/.env.example b/.env.dev.example
similarity index 52%
rename from .env.example
rename to .env.dev.example
index 7a67045c..926a0e41 100644
--- a/.env.example
+++ b/.env.dev.example
@@ -5,28 +5,39 @@ SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr"
DEBUG=true
DOMAIN=your.domain.here
+#EMAIL=your@email.here
+
+# Used for deciding which editions to prefer
+DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
-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=fedireads
+POSTGRES_PORT=5432
+POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
POSTGRES_HOST=db
-CELERY_BROKER=redis://redis:6379/0
-CELERY_RESULT_BACKEND=redis://redis:6379/0
+# Redis activity stream manager
+MAX_STREAM_LENGTH=200
+REDIS_ACTIVITY_HOST=redis_activity
+REDIS_ACTIVITY_PORT=6379
+#REDIS_ACTIVITY_PASSWORD=redispassword345
+
+# Redis as celery broker
+REDIS_BROKER_PORT=6379
+#REDIS_BROKER_PASSWORD=redispassword123
+
+FLOWER_PORT=8888
+#FLOWER_USER=mouse
+#FLOWER_PASSWORD=changeme
EMAIL_HOST="smtp.mailgun.org"
EMAIL_PORT=587
EMAIL_HOST_USER=mail@your.domain.here
EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
+EMAIL_USE_SSL=false
diff --git a/.env.prod.example b/.env.prod.example
new file mode 100644
index 00000000..4a7c87b6
--- /dev/null
+++ b/.env.prod.example
@@ -0,0 +1,43 @@
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr"
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG=false
+
+DOMAIN=your.domain.here
+EMAIL=your@email.here
+
+# Used for deciding which editions to prefer
+DEFAULT_LANGUAGE="English"
+
+## Leave unset to allow all hosts
+# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
+
+MEDIA_ROOT=images/
+
+POSTGRES_PORT=5432
+POSTGRES_PASSWORD=securedbypassword123
+POSTGRES_USER=fedireads
+POSTGRES_DB=fedireads
+POSTGRES_HOST=db
+
+# Redis activity stream manager
+MAX_STREAM_LENGTH=200
+REDIS_ACTIVITY_HOST=redis_activity
+REDIS_ACTIVITY_PORT=6379
+REDIS_ACTIVITY_PASSWORD=redispassword345
+
+# Redis as celery broker
+REDIS_BROKER_PORT=6379
+REDIS_BROKER_PASSWORD=redispassword123
+
+FLOWER_PORT=8888
+FLOWER_USER=mouse
+FLOWER_PASSWORD=changeme
+
+EMAIL_HOST="smtp.mailgun.org"
+EMAIL_PORT=587
+EMAIL_HOST_USER=mail@your.domain.here
+EMAIL_HOST_PASSWORD=emailpassword123
+EMAIL_USE_TLS=true
+EMAIL_USE_SSL=false
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 00000000..b2cd33f8
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1 @@
+**/vendor/**
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 00000000..b65fe988
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,90 @@
+/* global module */
+
+module.exports = {
+ "env": {
+ "browser": true,
+ "es6": true
+ },
+
+ "extends": "eslint:recommended",
+
+ "rules": {
+ // Possible Errors
+ "no-async-promise-executor": "error",
+ "no-await-in-loop": "error",
+ "no-class-assign": "error",
+ "no-confusing-arrow": "error",
+ "no-const-assign": "error",
+ "no-dupe-class-members": "error",
+ "no-duplicate-imports": "error",
+ "no-template-curly-in-string": "error",
+ "no-useless-computed-key": "error",
+ "no-useless-constructor": "error",
+ "no-useless-rename": "error",
+ "require-atomic-updates": "error",
+
+ // Best practices
+ "strict": "error",
+ "no-var": "error",
+
+ // Stylistic Issues
+ "arrow-spacing": "error",
+ "capitalized-comments": [
+ "warn",
+ "always",
+ {
+ "ignoreConsecutiveComments": true
+ },
+ ],
+ "keyword-spacing": "error",
+ "lines-around-comment": [
+ "error",
+ {
+ "beforeBlockComment": true,
+ "beforeLineComment": true,
+ "allowBlockStart": true,
+ "allowClassStart": true,
+ "allowObjectStart": true,
+ "allowArrayStart": true,
+ },
+ ],
+ "no-multiple-empty-lines": [
+ "error",
+ {
+ "max": 1,
+ },
+ ],
+ "padded-blocks": [
+ "error",
+ "never",
+ ],
+ "padding-line-between-statements": [
+ "error",
+ {
+ // always before return
+ "blankLine": "always",
+ "prev": "*",
+ "next": "return",
+ },
+ {
+ // always before block-like expressions
+ "blankLine": "always",
+ "prev": "*",
+ "next": "block-like",
+ },
+ {
+ // always after variable declaration
+ "blankLine": "always",
+ "prev": [ "const", "let", "var" ],
+ "next": "*",
+ },
+ {
+ // not necessary between variable declaration
+ "blankLine": "any",
+ "prev": [ "const", "let", "var" ],
+ "next": [ "const", "let", "var" ],
+ },
+ ],
+ "space-before-blocks": "error",
+ }
+};
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index dd84ea78..56af524e 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -24,15 +24,15 @@ A clear and concise description of what you expected to happen.
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- - OS: [e.g. iOS]
- - Browser [e.g. chrome, safari]
- - Version [e.g. 22]
+ - OS: [e.g. iOS]
+ - Browser [e.g. chrome, safari]
+ - Version [e.g. 22]
**Smartphone (please complete the following information):**
- - Device: [e.g. iPhone6]
- - OS: [e.g. iOS8.1]
- - Browser [e.g. stock browser, safari]
- - Version [e.g. 22]
+ - Device: [e.g. iPhone6]
+ - OS: [e.g. iOS8.1]
+ - Browser [e.g. stock browser, safari]
+ - Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
new file mode 100644
index 00000000..25af1e08
--- /dev/null
+++ b/.github/workflows/black.yml
@@ -0,0 +1,11 @@
+name: Lint Python
+
+on: [push, pull_request]
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - uses: actions/setup-python@v2
+ - uses: psf/black@21.4b2
diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml
index e734d18e..f9159efe 100644
--- a/.github/workflows/django-tests.yml
+++ b/.github/workflows/django-tests.yml
@@ -50,7 +50,6 @@ jobs:
SECRET_KEY: beepbeep
DEBUG: true
DOMAIN: your.domain.here
- OL_URL: https://openlibrary.org
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2
@@ -58,11 +57,12 @@ jobs:
POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
- CELERY_RESULT_BACKEND: ""
+ REDIS_BROKER_PORT: 6379
+ FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
run: |
- python manage.py test -v 3
+ python manage.py test
diff --git a/.github/workflows/lint-frontend.yaml b/.github/workflows/lint-frontend.yaml
new file mode 100644
index 00000000..54cac04d
--- /dev/null
+++ b/.github/workflows/lint-frontend.yaml
@@ -0,0 +1,38 @@
+# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
+name: Lint Frontend
+
+on:
+ push:
+ branches: [ main, ci, frontend ]
+ paths:
+ - '.github/workflows/**'
+ - 'static/**'
+ - '.eslintrc'
+ - '.stylelintrc'
+ pull_request:
+ branches: [ main, ci, frontend ]
+
+jobs:
+ lint:
+ name: Lint with stylelint and ESLint.
+ runs-on: ubuntu-20.04
+
+ steps:
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
+ - uses: actions/checkout@v2
+
+ - name: Install modules
+ run: yarn
+
+ # See .stylelintignore for files that are not linted.
+ - name: Run stylelint
+ run: >
+ yarn stylelint bookwyrm/static/**/*.css \
+ --report-needless-disables \
+ --report-invalid-scope-disables
+
+ # See .eslintignore for files that are not linted.
+ - name: Run ESLint
+ run: >
+ yarn eslint bookwyrm/static \
+ --ext .js,.jsx,.ts,.tsx
diff --git a/.github/workflows/lint-global.yaml b/.github/workflows/lint-global.yaml
new file mode 100644
index 00000000..81893970
--- /dev/null
+++ b/.github/workflows/lint-global.yaml
@@ -0,0 +1,21 @@
+# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
+name: Lint project globally
+
+on:
+ push:
+ branches: [ main, ci ]
+ pull_request:
+ branches: [ main, ci ]
+
+jobs:
+ lint:
+ name: Lint with EditorConfig.
+ runs-on: ubuntu-20.04
+
+ # Steps represent a sequence of tasks that will be executed as part of the job
+ steps:
+ # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
+ - uses: actions/checkout@v2
+
+ - name: EditorConfig
+ uses: greut/eclint-action@v0
diff --git a/.gitignore b/.gitignore
index 1384056f..cf88e987 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
/venv
*.pyc
*.swp
+**/__pycache__
+.local
# VSCode
/.vscode
@@ -15,4 +17,13 @@
/images/
# Testing
-.coverage
\ No newline at end of file
+.coverage
+
+#PyCharm
+.idea
+
+#Node tools
+/node_modules/
+
+#nginx
+nginx/default.conf
diff --git a/.stylelintignore b/.stylelintignore
new file mode 100644
index 00000000..b2cd33f8
--- /dev/null
+++ b/.stylelintignore
@@ -0,0 +1 @@
+**/vendor/**
diff --git a/.stylelintrc.js b/.stylelintrc.js
new file mode 100644
index 00000000..eadc4a89
--- /dev/null
+++ b/.stylelintrc.js
@@ -0,0 +1,17 @@
+/* global module */
+
+module.exports = {
+ "extends": "stylelint-config-standard",
+
+ "plugins": [
+ "stylelint-order"
+ ],
+
+ "rules": {
+ "order/order": [
+ "custom-properties",
+ "declarations"
+ ],
+ "indentation": 4
+ }
+};
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index a60597ae..3ab87444 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -23,13 +23,13 @@ include:
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
- advances
+advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
- address, without explicit permission
+address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- professional setting
+professional setting
## Our Responsibilities
diff --git a/Dockerfile b/Dockerfile
index 7456996e..1892ae23 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -2,14 +2,10 @@ FROM python:3.9
ENV PYTHONUNBUFFERED 1
-RUN mkdir /app
-RUN mkdir /app/static
-RUN mkdir /app/images
+RUN mkdir /app /app/static /app/images
WORKDIR /app
COPY requirements.txt /app/
-RUN pip install -r requirements.txt
-
-COPY ./bookwyrm /app
-COPY ./celerywyrm /app
+RUN pip install -r requirements.txt --no-cache-dir
+RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean
diff --git a/LICENSE b/LICENSE.md
similarity index 84%
rename from LICENSE
rename to LICENSE.md
index 96b7cd74..f111d3fe 100644
--- a/LICENSE
+++ b/LICENSE.md
@@ -9,10 +9,11 @@ Permission is hereby granted, free of charge, to any person or organization (the
1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software.
2. The User is one of the following:
-a. An individual person, laboring for themselves
-b. A non-profit organization
-c. An educational institution
-d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor
+
+ 1. An individual person, laboring for themselves
+ 2. A non-profit organization
+ 3. An educational institution
+ 4. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor
3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote.
diff --git a/README.md b/README.md
index 6a624ab3..161f91b9 100644
--- a/README.md
+++ b/README.md
@@ -6,36 +6,24 @@ Social reading and reviewing, decentralized with ActivityPub
- [Joining BookWyrm](#joining-bookwyrm)
- [Contributing](#contributing)
- [About BookWyrm](#about-bookwyrm)
- - [What it is and isn't](#what-it-is-and-isnt)
- - [The role of federation](#the-role-of-federation)
- - [Features](#features)
- - [Setting up the developer environment](#setting-up-the-developer-environment)
- - [Installing in Production](#installing-in-production)
- - [Book data](#book-data)
+ - [What it is and isn't](#what-it-is-and-isnt)
+ - [The role of federation](#the-role-of-federation)
+ - [Features](#features)
+- [Book data](#book-data)
+- [Set up Bookwyrm](#set-up-bookwyrm)
## Joining BookWyrm
-BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list.
+BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://docs.joinbookwyrm.com/instances.html) list.
-You can request an invite to https://bookwyrm.social by [email](mailto:mousereeve@riseup.net), [Mastodon direct message](https://friend.camp/@tripofmice), or [Twitter direct message](https://twitter.com/tripofmice).
+You can request an invite by entering your email address at https://bookwyrm.social.
## Contributing
-There are many ways you can contribute to this project, regardless of your level of technical expertise.
-
-### Feedback and feature requests
-Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues).
-
-### Code contributions
-Code contributons are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out.
-
-If you have questions about the project or contributing, you can seet up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
-
-### Financial Support
-BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo).
+See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
## About BookWyrm
### What it is and isn't
-BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a datasource for books, but it does do both of those things to some degree.
+BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
### The role of federation
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
@@ -43,125 +31,55 @@ BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub,
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
### 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!
- - Posting about books
+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
- Compose reviews, with or without ratings, which are aggregated in the book page
- Compose other kinds of statuses about books, such as:
- - Comments on a book
- - Quotes or excerpts
+ - Comments on a book
+ - Quotes or excerpts
- Reply to statuses
- View aggregate reviews of a book across connected BookWyrm instances
- Differentiate local and federated reviews and rating in your activity feed
- - Track reading activity
+- Track reading activity
- Shelve books on default "to-read," "currently reading," and "read" shelves
- Create custom shelves
- Store started reading/finished reading dates, as well as progress updates along the way
- Update followers about reading activity (optionally, and with granular privacy controls)
- Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
- - Federation with ActivityPub
+- Federation with ActivityPub
- Broadcast and receive user statuses and activity
- Share book data between instances to create a networked database of metadata
- Identify shared books across instances and aggregate related content
- Follow and interact with users across BookWyrm instances
- Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
- - Granular privacy controls
+- Granular privacy controls
- Private, followers-only, and public privacy levels for posting, shelves, and lists
- Option for users to manually approve followers
- Allow blocking and flagging for moderation
-
+
### The Tech Stack
Web backend
- - [Django](https://www.djangoproject.com/) web server
- - [PostgreSQL](https://www.postgresql.org/) database
- - [ActivityPub](http://activitypub.rocks/) federation
- - [Celery](http://celeryproject.org/) task queuing
- - [Redis](https://redis.io/) task backend
-
+- [Django](https://www.djangoproject.com/) web server
+- [PostgreSQL](https://www.postgresql.org/) database
+- [ActivityPub](https://activitypub.rocks/) federation
+- [Celery](https://docs.celeryproject.org/) task queuing
+- [Redis](https://redis.io/) task backend
+- [Redis (again)](https://redis.io/) activity stream manager
+
Front end
- - Django templates
- - [Bulma.io](https://bulma.io/) css framework
- - Vanilla JavaScript, in moderation
-
+- Django templates
+- [Bulma.io](https://bulma.io/) css framework
+- Vanilla JavaScript, in moderation
+
Deployment
- - [Docker](https://www.docker.com/) and docker-compose
- - [Gunicorn](https://gunicorn.org/) web runner
- - [Flower](https://github.com/mher/flower) celery monitoring
- - [Nginx](https://nginx.org/en/) HTTP server
-
-## Setting up the developer environment
-
-Set up the environment file:
-
-``` bash
-cp .env.example .env
-```
-
-For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain.
-
-You'll have to install the Docker and docker-compose. When you're ready, run:
-
-```bash
-docker-compose build
-docker-compose run --rm web python manage.py migrate
-docker-compose run --rm web python manage.py initdb
-docker-compose up
-```
-
-Once the build is complete, you can access the instance at `localhost:1333`
-
-## Installing in Production
-
-This project is still young and isn't, at the momoment, very stable, so please procede with caution when running in production.
-### Server setup
- - Get a domain name and set up DNS for your server
- - Set your server up with appropriate firewalls for running a web application (this instruction set is tested again Ubuntu 20.04)
- - Set up a mailgun account and the appropriate DNS settings
- - Install Docker and docker-compose
-### Install and configure BookWyrm
- - Get the application code:
- `git clone git@github.com:mouse-reeve/bookwyrm.git`
- - Switch to the `production` branch
- `git checkout production`
- - Create your environment variables file
- `cp .env.example .env`
- - Add your domain, email address, mailgun credentials
- - Set a secure redis password and secret key
- - Set a secure database password for postgres
- - Update your nginx configuration in `nginx/default.conf`
- - Replace `your-domain.com` with your domain name
- - Run the application (this should also set up a Certbot ssl cert for your domain)
- `docker-compose up --build`
- Make sure all the images build successfully
- - When docker has built successfully, stop the process with `CTRL-C`
- - Comment out the `command: certonly...` line in `docker-compose.yml`
- - Run docker-compose in the background
- `docker-compose up -d`
- - Initialize the database
- `./bw-dev initdb`
- - Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U ` and saves the backup to a safe locationgi
- - Congrats! You did it, go to your domain and enjoy the fruits of your labors
-### Configure your instance
- - Register a user account in the applcation UI
- - Make your account a superuser (warning: do *not* use django's `createsuperuser` command)
- - On your server, open the django shell
- `./bw-dev shell`
- - Load your user and make it a superuser
- ```python
- from bookwyrm import models
- user = models.User.objects.get(id=1)
- user.is_staff = True
- user.is_superuser = True
- user.save()
- ```
- - Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
+- [Docker](https://www.docker.com/) and docker-compose
+- [Gunicorn](https://gunicorn.org/) web runner
+- [Flower](https://github.com/mher/flower) celery monitoring
+- [Nginx](https://nginx.org/en/) HTTP server
## 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.
-There are three concepts in the book data model:
- - `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`
- - `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.
+## Set up Bookwyrm
+The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html).
diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py
index 510f1f3f..bfb22fa3 100644
--- a/bookwyrm/activitypub/__init__.py
+++ b/bookwyrm/activitypub/__init__.py
@@ -1,25 +1,31 @@
-''' bring activitypub functions into the namespace '''
+""" bring activitypub functions into the namespace """
import inspect
import sys
-from .base_activity import ActivityEncoder, Signature
+from .base_activity import ActivityEncoder, Signature, naive_parse
from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id
-from .image import Image
-from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
+from .image import Document, Image
+from .note import Note, GeneratedNote, Article, Comment, Quotation
+from .note import Review, Rating
from .note import Tombstone
-from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
+from .ordered_collection import CollectionItem, ListItem, ShelfItem
from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey
from .response import ActivitypubResponse
from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject, Block
-from .verbs import Add, AddBook, AddListItem, Remove
+from .verbs import Add, Remove
+from .verbs import Announce, Like
# this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
-activity_objects = {c[0]: c[1] for c in cls_members \
- if hasattr(c[1], 'to_model')}
+activity_objects = {c[0]: c[1] for c in cls_members if hasattr(c[1], "to_model")}
+
+
+def parse(activity_json):
+ """figure out what activity this is and parse it"""
+ return naive_parse(activity_objects, activity_json)
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index 5f35f1d7..5349e1dd 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -1,4 +1,4 @@
-''' basics for an activitypub serializer '''
+""" basics for an activitypub serializer """
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
@@ -8,86 +8,128 @@ from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
+
class ActivitySerializerError(ValueError):
- ''' routine problems serializing activitypub json '''
+ """routine problems serializing activitypub json"""
class ActivityEncoder(JSONEncoder):
- ''' used to convert an Activity object into json '''
+ """used to convert an Activity object into json"""
+
def default(self, o):
return o.__dict__
@dataclass
class Link:
- ''' for tagging a book in a status '''
+ """for tagging a book in a status"""
+
href: str
name: str
- type: str = 'Link'
+ type: str = "Link"
@dataclass
class Mention(Link):
- ''' a subtype of Link for mentioning an actor '''
- type: str = 'Mention'
+ """a subtype of Link for mentioning an actor"""
+
+ type: str = "Mention"
@dataclass
class Signature:
- ''' public key block '''
+ """public key block"""
+
creator: str
created: str
signatureValue: str
- type: str = 'RsaSignature2017'
+ type: str = "RsaSignature2017"
+
+
+def naive_parse(activity_objects, activity_json, serializer=None):
+ """this navigates circular import issues"""
+ if not serializer:
+ if activity_json.get("publicKeyPem"):
+ # ugh
+ activity_json["type"] = "PublicKey"
+
+ activity_type = activity_json.get("type")
+ try:
+ serializer = activity_objects[activity_type]
+ except KeyError as e:
+ # we know this exists and that we can't handle it
+ if activity_type in ["Question"]:
+ return None
+ raise ActivitySerializerError(e)
+
+ return serializer(activity_objects=activity_objects, **activity_json)
@dataclass(init=False)
class ActivityObject:
- ''' actor activitypub json '''
+ """actor activitypub json"""
+
id: str
type: str
- def __init__(self, **kwargs):
- ''' this lets you pass in an object with fields that aren't in the
+ def __init__(self, activity_objects=None, **kwargs):
+ """this lets you pass in an object with fields that aren't in the
dataclass, which it ignores. Any field in the dataclass is required or
- has a default value '''
+ has a default value"""
for field in fields(self):
try:
value = kwargs[field.name]
+ if value in (None, MISSING, {}):
+ raise KeyError()
+ try:
+ is_subclass = issubclass(field.type, ActivityObject)
+ except TypeError:
+ is_subclass = False
+ # serialize a model obj
+ if hasattr(value, "to_activity"):
+ value = value.to_activity()
+ # parse a dict into the appropriate activity
+ elif is_subclass and isinstance(value, dict):
+ if activity_objects:
+ value = naive_parse(activity_objects, value)
+ else:
+ value = naive_parse(
+ activity_objects, value, serializer=field.type
+ )
+
except KeyError:
- if field.default == MISSING and \
- field.default_factory == MISSING:
- raise ActivitySerializerError(\
- 'Missing required field: %s' % field.name)
+ if field.default == MISSING and field.default_factory == MISSING:
+ raise ActivitySerializerError(
+ "Missing required field: %s" % field.name
+ )
value = field.default
setattr(self, field.name, value)
+ def to_model(self, model=None, instance=None, allow_create=True, save=True):
+ """convert from an activity to a model instance"""
+ model = model or get_model_from_type(self.type)
- def to_model(self, model, instance=None, save=True):
- ''' convert from an activity to a model instance '''
- if self.type != model.activity_serializer.type:
- raise ActivitySerializerError(
- 'Wrong activity type "%s" for activity of type "%s"' % \
- (model.activity_serializer.type,
- self.type)
- )
+ # only reject statuses if we're potentially creating them
+ if (
+ allow_create
+ and hasattr(model, "ignore_activity")
+ and model.ignore_activity(self)
+ ):
+ return None
- if not isinstance(self, model.activity_serializer):
- raise ActivitySerializerError(
- 'Wrong activity type "%s" for model "%s" (expects "%s")' % \
- (self.__class__,
- model.__name__,
- model.activity_serializer)
- )
+ # check for an existing instance
+ instance = instance or model.find_existing(self.serialize())
- if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
- return instance
-
- # check for an existing instance, if we're not updating a known obj
- instance = instance or model.find_existing(self.serialize()) or model()
+ if not instance and not allow_create:
+ # so that we don't create when we want to delete or update
+ return None
+ instance = instance or model()
for field in instance.simple_fields:
- field.set_field_from_activity(instance, self)
+ try:
+ field.set_field_from_activity(instance, self)
+ except AttributeError as e:
+ raise ActivitySerializerError(e)
# image fields have to be set after other fields because they can save
# too early and jank up users
@@ -113,8 +155,10 @@ class ActivityObject:
field.set_field_from_activity(instance, self)
# reversed relationships in the models
- for (model_field_name, activity_field_name) in \
- instance.deserialize_reverse_fields:
+ for (
+ model_field_name,
+ activity_field_name,
+ ) in instance.deserialize_reverse_fields:
# attachments on Status, for example
values = getattr(self, activity_field_name)
if values is None or values is MISSING:
@@ -132,30 +176,33 @@ class ActivityObject:
instance.__class__.__name__,
related_field_name,
instance.remote_id,
- item
+ item,
)
return instance
-
def serialize(self):
- ''' convert to dictionary with context attr '''
- data = self.__dict__
- data = {k:v for (k, v) in data.items() if v is not None}
- data['@context'] = 'https://www.w3.org/ns/activitystreams'
+ """convert to dictionary with context attr"""
+ data = self.__dict__.copy()
+ # recursively serialize
+ for (k, v) in data.items():
+ try:
+ if issubclass(type(v), ActivityObject):
+ data[k] = v.serialize()
+ except TypeError:
+ pass
+ data = {k: v for (k, v) in data.items() if v is not None}
+ data["@context"] = "https://www.w3.org/ns/activitystreams"
return data
@app.task
@transaction.atomic
def set_related_field(
- model_name, origin_model_name, related_field_name,
- related_remote_id, data):
- ''' load reverse related fields (editions, attachments) without blocking '''
- model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True)
- origin_model = apps.get_model(
- 'bookwyrm.%s' % origin_model_name,
- require_ready=True
- )
+ model_name, origin_model_name, related_field_name, related_remote_id, data
+):
+ """load reverse related fields (editions, attachments) without blocking"""
+ model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
+ origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
with transaction.atomic():
if isinstance(data, str):
@@ -169,48 +216,71 @@ def set_related_field(
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
- raise ValueError(
- 'Invalid related remote id: %s' % related_remote_id)
+ raise ValueError("Invalid related remote id: %s" % related_remote_id)
# set the origin's remote id on the activity so it will be there when
# the model instance is created
# edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
- if hasattr(model_field, 'activitypub_field'):
+ if hasattr(model_field, "activitypub_field"):
setattr(
- activity,
- getattr(model_field, 'activitypub_field'),
- instance.remote_id
+ activity, getattr(model_field, "activitypub_field"), instance.remote_id
)
- item = activity.to_model(model)
+ item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
- if not hasattr(model_field, 'activitypub_field'):
+ if not hasattr(model_field, "activitypub_field"):
setattr(item, related_field_name, instance)
item.save()
-def resolve_remote_id(model, remote_id, refresh=False, save=True):
- ''' take a remote_id and return an instance, creating if necessary '''
- result = model.find_existing_by_remote_id(remote_id)
- if result and not refresh:
- return result
+def get_model_from_type(activity_type):
+ """given the activity, what type of model"""
+ models = apps.get_models()
+ model = [
+ m
+ for m in models
+ if hasattr(m, "activity_serializer")
+ and hasattr(m.activity_serializer, "type")
+ and m.activity_serializer.type == activity_type
+ ]
+ if not model:
+ raise ActivitySerializerError(
+ 'No model found for activity type "%s"' % activity_type
+ )
+ return model[0]
+
+
+def resolve_remote_id(
+ remote_id, model=None, refresh=False, save=True, get_activity=False
+):
+ """take a remote_id and return an instance, creating if necessary"""
+ if model: # a bonus check we can do if we already know the model
+ result = model.find_existing_by_remote_id(remote_id)
+ if result and not refresh:
+ return result if not get_activity else result.to_activity_dataclass()
# load the data and create the object
try:
data = get_data(remote_id)
- except (ConnectorException, ConnectionError):
+ except ConnectorException:
raise ActivitySerializerError(
- 'Could not connect to host for remote_id in %s model: %s' % \
- (model.__name__, remote_id))
+ "Could not connect to host for remote_id in: %s" % (remote_id)
+ )
+ # determine the model implicitly, if not provided
+ # or if it's a model with subclasses like Status, check again
+ if not model or hasattr(model.objects, "select_subclasses"):
+ model = get_model_from_type(data.get("type"))
# check for existing items with shared unique identifiers
- if not result:
- result = model.find_existing(data)
- if result and not refresh:
- return result
+ result = model.find_existing(data)
+ if result and not refresh:
+ return result if not get_activity else result.to_activity_dataclass()
item = model.activity_serializer(**data)
+ if get_activity:
+ return item
+
# if we're refreshing, "result" will be set and we'll update it
- return item.to_model(model, instance=result, save=save)
+ return item.to_model(model=model, instance=result, save=save)
diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py
index 68036559..1599b408 100644
--- a/bookwyrm/activitypub/book.py
+++ b/bookwyrm/activitypub/book.py
@@ -1,70 +1,82 @@
-''' book and author data '''
+""" book and author data """
from dataclasses import dataclass, field
from typing import List
from .base_activity import ActivityObject
-from .image import Image
+from .image import Document
+
@dataclass(init=False)
-class Book(ActivityObject):
- ''' serializes an edition or work, abstract '''
+class BookData(ActivityObject):
+ """shared fields for all book data and authors"""
+
+ openlibraryKey: str = None
+ inventaireId: str = None
+ librarythingKey: str = None
+ goodreadsKey: str = None
+ bnfId: str = None
+ lastEditedBy: str = None
+
+
+@dataclass(init=False)
+class Book(BookData):
+ """serializes an edition or work, abstract"""
+
title: str
- sortTitle: str = ''
- subtitle: str = ''
- description: str = ''
+ sortTitle: str = ""
+ subtitle: str = ""
+ description: str = ""
languages: List[str] = field(default_factory=lambda: [])
- series: str = ''
- seriesNumber: str = ''
+ series: str = ""
+ seriesNumber: str = ""
subjects: List[str] = field(default_factory=lambda: [])
subjectPlaces: List[str] = field(default_factory=lambda: [])
authors: List[str] = field(default_factory=lambda: [])
- firstPublishedDate: str = ''
- publishedDate: str = ''
+ firstPublishedDate: str = ""
+ publishedDate: str = ""
- openlibraryKey: str = ''
- librarythingKey: str = ''
- goodreadsKey: str = ''
-
- cover: Image = field(default_factory=lambda: {})
- type: str = 'Book'
+ cover: Document = None
+ type: str = "Book"
@dataclass(init=False)
class Edition(Book):
- ''' Edition instance of a book object '''
+ """Edition instance of a book object"""
+
work: str
- isbn10: str = ''
- isbn13: str = ''
- oclcNumber: str = ''
- asin: str = ''
+ isbn10: str = ""
+ isbn13: str = ""
+ oclcNumber: str = ""
+ asin: str = ""
pages: int = None
- physicalFormat: str = ''
+ physicalFormat: str = ""
publishers: List[str] = field(default_factory=lambda: [])
editionRank: int = 0
- type: str = 'Edition'
+ type: str = "Edition"
@dataclass(init=False)
class Work(Book):
- ''' work instance of a book object '''
- lccn: str = ''
- defaultEdition: str = ''
+ """work instance of a book object"""
+
+ lccn: str = ""
editions: List[str] = field(default_factory=lambda: [])
- type: str = 'Work'
+ type: str = "Work"
@dataclass(init=False)
-class Author(ActivityObject):
- ''' author of a book '''
+class Author(BookData):
+ """author of a book"""
+
name: str
+ isni: str = None
+ viafId: str = None
+ gutenbergId: str = None
born: str = None
died: str = None
aliases: List[str] = field(default_factory=lambda: [])
- bio: str = ''
- openlibraryKey: str = ''
- librarythingKey: str = ''
- goodreadsKey: str = ''
- wikipediaLink: str = ''
- type: str = 'Person'
+ bio: str = ""
+ wikipediaLink: str = ""
+ type: str = "Author"
diff --git a/bookwyrm/activitypub/image.py b/bookwyrm/activitypub/image.py
index 569f83c5..7950faaf 100644
--- a/bookwyrm/activitypub/image.py
+++ b/bookwyrm/activitypub/image.py
@@ -1,11 +1,20 @@
-''' an image, nothing fancy '''
+""" an image, nothing fancy """
from dataclasses import dataclass
from .base_activity import ActivityObject
+
@dataclass(init=False)
-class Image(ActivityObject):
- ''' image block '''
+class Document(ActivityObject):
+ """a document"""
+
url: str
- name: str = ''
- type: str = 'Image'
- id: str = ''
+ name: str = ""
+ type: str = "Document"
+ id: str = None
+
+
+@dataclass(init=False)
+class Image(Document):
+ """an image"""
+
+ type: str = "Image"
diff --git a/bookwyrm/activitypub/interaction.py b/bookwyrm/activitypub/interaction.py
deleted file mode 100644
index 752b2fe3..00000000
--- a/bookwyrm/activitypub/interaction.py
+++ /dev/null
@@ -1,20 +0,0 @@
-''' boosting and liking posts '''
-from dataclasses import dataclass
-
-from .base_activity import ActivityObject
-
-
-@dataclass(init=False)
-class Like(ActivityObject):
- ''' a user faving an object '''
- actor: str
- object: str
- type: str = 'Like'
-
-
-@dataclass(init=False)
-class Boost(ActivityObject):
- ''' boosting a status '''
- actor: str
- object: str
- type: str = 'Announce'
diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py
index 1e0bdcb7..ea2e92b6 100644
--- a/bookwyrm/activitypub/note.py
+++ b/bookwyrm/activitypub/note.py
@@ -1,65 +1,87 @@
-''' note serializer and children thereof '''
+""" note serializer and children thereof """
from dataclasses import dataclass, field
from typing import Dict, List
+from django.apps import apps
from .base_activity import ActivityObject, Link
-from .image import Image
+from .image import Document
+
@dataclass(init=False)
class Tombstone(ActivityObject):
- ''' the placeholder for a deleted status '''
- published: str
- deleted: str
- type: str = 'Tombstone'
+ """the placeholder for a deleted status"""
+
+ type: str = "Tombstone"
+
+ def to_model(self, *args, **kwargs): # pylint: disable=unused-argument
+ """this should never really get serialized, just searched for"""
+ model = apps.get_model("bookwyrm.Status")
+ return model.find_existing_by_remote_id(self.id)
@dataclass(init=False)
class Note(ActivityObject):
- ''' Note activity '''
+ """Note activity"""
+
published: str
attributedTo: str
- content: str = ''
+ content: str = ""
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})
- inReplyTo: str = ''
- summary: str = ''
+ inReplyTo: str = ""
+ summary: str = ""
tag: List[Link] = field(default_factory=lambda: [])
- attachment: List[Image] = field(default_factory=lambda: [])
+ attachment: List[Document] = field(default_factory=lambda: [])
sensitive: bool = False
- type: str = 'Note'
+ type: str = "Note"
@dataclass(init=False)
class Article(Note):
- ''' what's an article except a note with more fields '''
+ """what's an article except a note with more fields"""
+
name: str
- type: str = 'Article'
+ type: str = "Article"
@dataclass(init=False)
class GeneratedNote(Note):
- ''' just a re-typed note '''
- type: str = 'GeneratedNote'
+ """just a re-typed note"""
+
+ type: str = "GeneratedNote"
@dataclass(init=False)
class Comment(Note):
- ''' like a note but with a book '''
+ """like a note but with a book"""
+
inReplyToBook: str
- type: str = 'Comment'
-
-
-@dataclass(init=False)
-class Review(Comment):
- ''' a full book review '''
- name: str = None
- rating: int = None
- type: str = 'Review'
+ type: str = "Comment"
@dataclass(init=False)
class Quotation(Comment):
- ''' a quote and commentary on a book '''
+ """a quote and commentary on a book"""
+
quote: str
- type: str = 'Quotation'
+ type: str = "Quotation"
+
+
+@dataclass(init=False)
+class Review(Comment):
+ """a full book review"""
+
+ name: str = None
+ rating: int = None
+ type: str = "Review"
+
+
+@dataclass(init=False)
+class Rating(Comment):
+ """just a star rating"""
+
+ rating: int
+ content: str = None
+ name: str = None # not used, but the model inherits from Review
+ type: str = "Rating"
diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py
index cf642994..e3a83be8 100644
--- a/bookwyrm/activitypub/ordered_collection.py
+++ b/bookwyrm/activitypub/ordered_collection.py
@@ -1,4 +1,4 @@
-''' defines activitypub collections (lists) '''
+""" defines activitypub collections (lists) """
from dataclasses import dataclass, field
from typing import List
@@ -7,37 +7,73 @@ from .base_activity import ActivityObject
@dataclass(init=False)
class OrderedCollection(ActivityObject):
- ''' structure of an ordered collection activity '''
+ """structure of an ordered collection activity"""
+
totalItems: int
first: str
last: str = None
name: str = None
owner: str = None
- type: str = 'OrderedCollection'
+ type: str = "OrderedCollection"
+
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
+ """an ordered collection with privacy settings"""
+
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
+
@dataclass(init=False)
class Shelf(OrderedCollectionPrivate):
- ''' structure of an ordered collection activity '''
- type: str = 'Shelf'
+ """structure of an ordered collection activity"""
+
+ type: str = "Shelf"
+
@dataclass(init=False)
class BookList(OrderedCollectionPrivate):
- ''' structure of an ordered collection activity '''
+ """structure of an ordered collection activity"""
+
summary: str = None
- curation: str = 'closed'
- type: str = 'BookList'
+ curation: str = "closed"
+ type: str = "BookList"
@dataclass(init=False)
class OrderedCollectionPage(ActivityObject):
- ''' structure of an ordered collection activity '''
+ """structure of an ordered collection activity"""
+
partOf: str
orderedItems: List
- next: str
- prev: str
- type: str = 'OrderedCollectionPage'
+ next: str = None
+ prev: str = None
+ 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"
diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py
index 7e7d027e..d5f37946 100644
--- a/bookwyrm/activitypub/person.py
+++ b/bookwyrm/activitypub/person.py
@@ -1,4 +1,4 @@
-''' actor serializer '''
+""" actor serializer """
from dataclasses import dataclass, field
from typing import Dict
@@ -8,25 +8,28 @@ from .image import Image
@dataclass(init=False)
class PublicKey(ActivityObject):
- ''' public key block '''
+ """public key block"""
+
owner: str
publicKeyPem: str
- type: str = 'PublicKey'
+ type: str = "PublicKey"
@dataclass(init=False)
class Person(ActivityObject):
- ''' actor activitypub json '''
+ """actor activitypub json"""
+
preferredUsername: str
inbox: str
- outbox: str
- followers: str
publicKey: PublicKey
- endpoints: Dict
+ followers: str = None
+ following: str = None
+ outbox: str = None
+ endpoints: Dict = None
name: str = None
summary: str = None
icon: Image = field(default_factory=lambda: {})
bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False
- discoverable: str = True
- type: str = 'Person'
+ discoverable: str = False
+ type: str = "Person"
diff --git a/bookwyrm/activitypub/response.py b/bookwyrm/activitypub/response.py
index bbc44c4d..07f39c7e 100644
--- a/bookwyrm/activitypub/response.py
+++ b/bookwyrm/activitypub/response.py
@@ -2,6 +2,7 @@ from django.http import JsonResponse
from .base_activity import ActivityEncoder
+
class ActivitypubResponse(JsonResponse):
"""
A class to be used in any place that's serializing responses for
@@ -9,10 +10,17 @@ class ActivitypubResponse(JsonResponse):
configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse.
"""
- def __init__(self, data, encoder=ActivityEncoder, safe=True,
- json_dumps_params=None, **kwargs):
- if 'content_type' not in kwargs:
- kwargs['content_type'] = 'application/activity+json'
+ def __init__(
+ self,
+ data,
+ encoder=ActivityEncoder,
+ safe=False,
+ json_dumps_params=None,
+ **kwargs
+ ):
+
+ if "content_type" not in kwargs:
+ kwargs["content_type"] = "application/activity+json"
super().__init__(data, encoder, safe, json_dumps_params, **kwargs)
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index 190cd739..f26936d7 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -1,97 +1,207 @@
-''' undo wrapper activity '''
-from dataclasses import dataclass
+""" activities that do things """
+from dataclasses import dataclass, field
from typing import List
+from django.apps import apps
+
+from .base_activity import ActivityObject, Signature, resolve_remote_id
+from .ordered_collection import CollectionItem
-from .base_activity import ActivityObject, Signature
-from .book import Edition
@dataclass(init=False)
class Verb(ActivityObject):
- ''' generic fields for activities - maybe an unecessary level of
- abstraction but w/e '''
+ """generic fields for activities"""
+
actor: str
object: ActivityObject
+ def action(self):
+ """usually we just want to update and save"""
+ # self.object may return None if the object is invalid in an expected way
+ # ie, Question type
+ if self.object:
+ self.object.to_model()
+
@dataclass(init=False)
class Create(Verb):
- ''' Create activity '''
- to: List
- cc: List
+ """Create activity"""
+
+ to: List[str]
+ cc: List[str] = field(default_factory=lambda: [])
signature: Signature = None
- type: str = 'Create'
+ type: str = "Create"
@dataclass(init=False)
class Delete(Verb):
- ''' Create activity '''
- to: List
- cc: List
- type: str = 'Delete'
+ """Create activity"""
+
+ to: List[str]
+ cc: List[str] = field(default_factory=lambda: [])
+ type: str = "Delete"
+
+ def action(self):
+ """find and delete the activity object"""
+ if not self.object:
+ return
+
+ if isinstance(self.object, str):
+ # Deleted users are passed as strings. Not wild about this fix
+ model = apps.get_model("bookwyrm.User")
+ obj = model.find_existing_by_remote_id(self.object)
+ else:
+ obj = self.object.to_model(save=False, allow_create=False)
+
+ if obj:
+ obj.delete()
+ # if we can't find it, we don't need to delete it because we don't have it
@dataclass(init=False)
class Update(Verb):
- ''' Update activity '''
- to: List
- type: str = 'Update'
+ """Update activity"""
+
+ to: List[str]
+ type: str = "Update"
+
+ def action(self):
+ """update a model instance from the dataclass"""
+ if self.object:
+ self.object.to_model(allow_create=False)
@dataclass(init=False)
class Undo(Verb):
- ''' Undo an activity '''
- type: str = 'Undo'
+ """Undo an activity"""
+
+ type: str = "Undo"
+
+ def action(self):
+ """find and remove the activity object"""
+ if isinstance(self.object, str):
+ # it may be that sometihng should be done with these, but idk what
+ # this seems just to be coming from pleroma
+ return
+
+ # this is so hacky but it does make it work....
+ # (because you Reject a request and Undo a follow
+ model = None
+ if self.object.type == "Follow":
+ model = apps.get_model("bookwyrm.UserFollows")
+ obj = self.object.to_model(model=model, save=False, allow_create=False)
+ if not obj:
+ # this could be a folloq request not a follow proper
+ model = apps.get_model("bookwyrm.UserFollowRequest")
+ obj = self.object.to_model(model=model, save=False, allow_create=False)
+ else:
+ obj = self.object.to_model(model=model, save=False, allow_create=False)
+ if not obj:
+ # if we don't have the object, we can't undo it. happens a lot with boosts
+ return
+ obj.delete()
@dataclass(init=False)
class Follow(Verb):
- ''' Follow activity '''
- type: str = 'Follow'
+ """Follow activity"""
+
+ object: str
+ type: str = "Follow"
+
+ def action(self):
+ """relationship save"""
+ self.to_model()
+
@dataclass(init=False)
class Block(Verb):
- ''' Block activity '''
- type: str = 'Block'
+ """Block activity"""
+
+ object: str
+ type: str = "Block"
+
+ def action(self):
+ """relationship save"""
+ self.to_model()
+
@dataclass(init=False)
class Accept(Verb):
- ''' Accept activity '''
+ """Accept activity"""
+
object: Follow
- type: str = 'Accept'
+ type: str = "Accept"
+
+ def action(self):
+ """find and remove the activity object"""
+ obj = self.object.to_model(save=False, allow_create=False)
+ obj.accept()
@dataclass(init=False)
class Reject(Verb):
- ''' Reject activity '''
+ """Reject activity"""
+
object: Follow
- type: str = 'Reject'
+ type: str = "Reject"
+
+ def action(self):
+ """find and remove the activity object"""
+ obj = self.object.to_model(save=False, allow_create=False)
+ obj.reject()
@dataclass(init=False)
class Add(Verb):
- '''Add activity '''
- target: str
- object: ActivityObject
- type: str = 'Add'
+ """Add activity"""
-
-@dataclass(init=False)
-class AddBook(Add):
- '''Add activity that's aware of the book obj '''
- object: Edition
- type: str = 'Add'
-
-
-@dataclass(init=False)
-class AddListItem(AddBook):
- '''Add activity that's aware of the book obj '''
- notes: str = None
- order: int = 0
- approved: bool = True
-
-
-@dataclass(init=False)
-class Remove(Verb):
- '''Remove activity '''
target: ActivityObject
- type: str = 'Remove'
+ object: CollectionItem
+ type: str = "Add"
+
+ def action(self):
+ """figure out the target to assign the item to a collection"""
+ target = resolve_remote_id(self.target)
+ item = self.object.to_model(save=False)
+ setattr(item, item.collection_field, target)
+ item.save()
+
+
+@dataclass(init=False)
+class Remove(Add):
+ """Remove activity"""
+
+ type: str = "Remove"
+
+ def action(self):
+ """find and remove the activity object"""
+ obj = self.object.to_model(save=False, allow_create=False)
+ if obj:
+ obj.delete()
+
+
+@dataclass(init=False)
+class Like(Verb):
+ """a user faving an object"""
+
+ object: str
+ type: str = "Like"
+
+ def action(self):
+ """like"""
+ self.to_model()
+
+
+@dataclass(init=False)
+class Announce(Verb):
+ """boosting a status"""
+
+ published: str
+ to: List[str] = field(default_factory=lambda: [])
+ cc: List[str] = field(default_factory=lambda: [])
+ object: str
+ type: str = "Announce"
+
+ def action(self):
+ """boost"""
+ self.to_model()
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
new file mode 100644
index 00000000..ff3c55fb
--- /dev/null
+++ b/bookwyrm/activitystreams.py
@@ -0,0 +1,271 @@
+""" access the activity streams stored in redis """
+from django.dispatch import receiver
+from django.db.models import signals, Q
+
+from bookwyrm import models
+from bookwyrm.redis_store import RedisStore, r
+from bookwyrm.views.helpers import privacy_filter
+
+
+class ActivityStream(RedisStore):
+ """a category of activity stream (like home, local, federated)"""
+
+ def stream_id(self, user):
+ """the redis key for this user's instance of this stream"""
+ return "{}-{}".format(user.id, self.key)
+
+ def unread_id(self, user):
+ """the redis key for this user's unread count for this stream"""
+ return "{}-unread".format(self.stream_id(user))
+
+ def get_rank(self, obj): # pylint: disable=no-self-use
+ """statuses are sorted by date published"""
+ return obj.published_date.timestamp()
+
+ def add_status(self, status):
+ """add a status to users' feeds"""
+ # the pipeline contains all the add-to-stream activities
+ pipeline = self.add_object_to_related_stores(status, execute=False)
+
+ for user in self.get_audience(status):
+ # add to the unread status count
+ pipeline.incr(self.unread_id(user))
+
+ # and go!
+ pipeline.execute()
+
+ def add_user_statuses(self, viewer, user):
+ """add a user's statuses to another user's feed"""
+ # only add the statuses that the viewer should be able to see (ie, not dms)
+ statuses = privacy_filter(viewer, user.status_set.all())
+ self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
+
+ def remove_user_statuses(self, viewer, user):
+ """remove a user's status from another user's feed"""
+ # remove all so that followers only statuses are removed
+ statuses = user.status_set.all()
+ self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
+
+ def get_activity_stream(self, user):
+ """load the statuses to be displayed"""
+ # clear unreads for this feed
+ r.set(self.unread_id(user), 0)
+
+ statuses = self.get_store(self.stream_id(user))
+ return (
+ models.Status.objects.select_subclasses()
+ .filter(id__in=statuses)
+ .select_related("user", "reply_parent")
+ .prefetch_related("mention_books", "mention_users")
+ .order_by("-published_date")
+ )
+
+ def get_unread_count(self, user):
+ """get the unread status count for this user's feed"""
+ return int(r.get(self.unread_id(user)) or 0)
+
+ def populate_streams(self, user):
+ """go from zero to a timeline"""
+ self.populate_store(self.stream_id(user))
+
+ def get_audience(self, status): # pylint: disable=no-self-use
+ """given a status, what users should see it"""
+ # direct messages don't appeard in feeds, direct comments/reviews/etc do
+ if status.privacy == "direct" and status.status_type == "Note":
+ return []
+
+ # everybody who could plausibly see this status
+ audience = models.User.objects.filter(
+ is_active=True,
+ local=True, # we only create feeds for users of this instance
+ ).exclude(
+ Q(id__in=status.user.blocks.all()) | Q(blocks=status.user) # not blocked
+ )
+
+ # only visible to the poster and mentioned users
+ if status.privacy == "direct":
+ audience = audience.filter(
+ Q(id=status.user.id) # if the user is the post's author
+ | Q(id__in=status.mention_users.all()) # if the user is mentioned
+ )
+ # only visible to the poster's followers and tagged users
+ elif status.privacy == "followers":
+ audience = audience.filter(
+ Q(id=status.user.id) # if the user is the post's author
+ | Q(following=status.user) # if the user is following the author
+ )
+ return audience.distinct()
+
+ def get_stores_for_object(self, obj):
+ return [self.stream_id(u) for u in self.get_audience(obj)]
+
+ def get_statuses_for_user(self, user): # pylint: disable=no-self-use
+ """given a user, what statuses should they see on this stream"""
+ return privacy_filter(
+ user,
+ models.Status.objects.select_subclasses(),
+ privacy_levels=["public", "unlisted", "followers"],
+ )
+
+ def get_objects_for_store(self, store):
+ user = models.User.objects.get(id=store.split("-")[0])
+ return self.get_statuses_for_user(user)
+
+
+class HomeStream(ActivityStream):
+ """users you follow"""
+
+ key = "home"
+
+ def get_audience(self, status):
+ audience = super().get_audience(status)
+ if not audience:
+ return []
+ return audience.filter(
+ Q(id=status.user.id) # if the user is the post's author
+ | Q(following=status.user) # if the user is following the author
+ ).distinct()
+
+ def get_statuses_for_user(self, user):
+ return privacy_filter(
+ user,
+ models.Status.objects.select_subclasses(),
+ privacy_levels=["public", "unlisted", "followers"],
+ following_only=True,
+ )
+
+
+class LocalStream(ActivityStream):
+ """users you follow"""
+
+ key = "local"
+
+ def get_audience(self, status):
+ # this stream wants no part in non-public statuses
+ if status.privacy != "public" or not status.user.local:
+ return []
+ return super().get_audience(status)
+
+ def get_statuses_for_user(self, user):
+ # all public statuses by a local user
+ return privacy_filter(
+ user,
+ models.Status.objects.select_subclasses().filter(user__local=True),
+ privacy_levels=["public"],
+ )
+
+
+class FederatedStream(ActivityStream):
+ """users you follow"""
+
+ key = "federated"
+
+ def get_audience(self, status):
+ # this stream wants no part in non-public statuses
+ if status.privacy != "public":
+ return []
+ return super().get_audience(status)
+
+ def get_statuses_for_user(self, user):
+ return privacy_filter(
+ user,
+ models.Status.objects.select_subclasses(),
+ privacy_levels=["public"],
+ )
+
+
+streams = {
+ "home": HomeStream(),
+ "local": LocalStream(),
+ "federated": FederatedStream(),
+}
+
+
+@receiver(signals.post_save)
+# pylint: disable=unused-argument
+def add_status_on_create(sender, instance, created, *args, **kwargs):
+ """add newly created statuses to activity feeds"""
+ # we're only interested in new statuses
+ if not issubclass(sender, models.Status):
+ return
+
+ if instance.deleted:
+ for stream in streams.values():
+ stream.remove_object_from_related_stores(instance)
+ return
+
+ if not created:
+ return
+
+ # iterates through Home, Local, Federated
+ for stream in streams.values():
+ stream.add_status(instance)
+
+
+@receiver(signals.post_delete, sender=models.Boost)
+# pylint: disable=unused-argument
+def remove_boost_on_delete(sender, instance, *args, **kwargs):
+ """boosts are deleted"""
+ # we're only interested in new statuses
+ for stream in streams.values():
+ stream.remove_object_from_related_stores(instance)
+
+
+@receiver(signals.post_save, sender=models.UserFollows)
+# pylint: disable=unused-argument
+def add_statuses_on_follow(sender, instance, created, *args, **kwargs):
+ """add a newly followed user's statuses to feeds"""
+ if not created or not instance.user_subject.local:
+ return
+ HomeStream().add_user_statuses(instance.user_subject, instance.user_object)
+
+
+@receiver(signals.post_delete, sender=models.UserFollows)
+# pylint: disable=unused-argument
+def remove_statuses_on_unfollow(sender, instance, *args, **kwargs):
+ """remove statuses from a feed on unfollow"""
+ if not instance.user_subject.local:
+ return
+ HomeStream().remove_user_statuses(instance.user_subject, instance.user_object)
+
+
+@receiver(signals.post_save, sender=models.UserBlocks)
+# pylint: disable=unused-argument
+def remove_statuses_on_block(sender, instance, *args, **kwargs):
+ """remove statuses from all feeds on block"""
+ # blocks apply ot all feeds
+ if instance.user_subject.local:
+ for stream in streams.values():
+ stream.remove_user_statuses(instance.user_subject, instance.user_object)
+
+ # and in both directions
+ if instance.user_object.local:
+ for stream in streams.values():
+ stream.remove_user_statuses(instance.user_object, instance.user_subject)
+
+
+@receiver(signals.post_delete, sender=models.UserBlocks)
+# pylint: disable=unused-argument
+def add_statuses_on_unblock(sender, instance, *args, **kwargs):
+ """remove statuses from all feeds on block"""
+ public_streams = [LocalStream(), FederatedStream()]
+ # add statuses back to streams with statuses from anyone
+ if instance.user_subject.local:
+ for stream in public_streams:
+ stream.add_user_statuses(instance.user_subject, instance.user_object)
+
+ # add statuses back to streams with statuses from anyone
+ if instance.user_object.local:
+ for stream in public_streams:
+ stream.add_user_statuses(instance.user_object, instance.user_subject)
+
+
+@receiver(signals.post_save, sender=models.User)
+# pylint: disable=unused-argument
+def populate_streams_on_account_create(sender, instance, created, *args, **kwargs):
+ """build a user's feeds when they join"""
+ if not created or not instance.local:
+ return
+
+ for stream in streams.values():
+ stream.populate_streams(instance)
diff --git a/bookwyrm/admin.py b/bookwyrm/admin.py
index 45af81d9..f028dea0 100644
--- a/bookwyrm/admin.py
+++ b/bookwyrm/admin.py
@@ -1,8 +1,7 @@
-''' models that will show up in django admin for superuser '''
+""" models that will show up in django admin for superuser """
from django.contrib import admin
from bookwyrm import models
-admin.site.register(models.SiteSettings)
admin.site.register(models.User)
admin.site.register(models.FederatedServer)
admin.site.register(models.Connector)
diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py
index cfafd286..689f2701 100644
--- a/bookwyrm/connectors/__init__.py
+++ b/bookwyrm/connectors/__init__.py
@@ -1,4 +1,4 @@
-''' bring connectors into the namespace '''
+""" bring connectors into the namespace """
from .settings import CONNECTORS
from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index 527d2f42..60667815 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -1,4 +1,4 @@
-''' functionality outline for a book data connector '''
+""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
import logging
@@ -13,8 +13,11 @@ from .connector_manager import load_more_data, ConnectorException
logger = logging.getLogger(__name__)
+
+
class AbstractMinimalConnector(ABC):
- ''' just the bare bones, for other bookwyrm instances '''
+ """just the bare bones, for other bookwyrm instances"""
+
def __init__(self, identifier):
# load connector settings
info = models.Connector.objects.get(identifier=identifier)
@@ -22,88 +25,95 @@ class AbstractMinimalConnector(ABC):
# the things in the connector model to copy over
self_fields = [
- 'base_url',
- 'books_url',
- 'covers_url',
- 'search_url',
- 'max_query_count',
- 'name',
- 'identifier',
- 'local'
+ "base_url",
+ "books_url",
+ "covers_url",
+ "search_url",
+ "isbn_search_url",
+ "name",
+ "identifier",
+ "local",
]
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None):
- ''' free text search '''
+ """free text search"""
params = {}
if min_confidence:
- params['min_confidence'] = min_confidence
+ params["min_confidence"] = min_confidence
- resp = requests.get(
- '%s%s' % (self.search_url, query),
+ data = self.get_search_data(
+ "%s%s" % (self.search_url, query),
params=params,
- headers={
- 'Accept': 'application/json; charset=utf-8',
- 'User-Agent': settings.USER_AGENT,
- },
)
- if not resp.ok:
- resp.raise_for_status()
- try:
- data = resp.json()
- except ValueError as e:
- logger.exception(e)
- raise ConnectorException('Unable to parse json response', e)
results = []
for doc in self.parse_search_data(data)[:10]:
results.append(self.format_search_result(doc))
return results
+ def isbn_search(self, query):
+ """isbn search"""
+ params = {}
+ data = self.get_search_data(
+ "%s%s" % (self.isbn_search_url, query),
+ params=params,
+ )
+ results = []
+
+ # this shouldn't be returning mutliple results, but just in case
+ for doc in self.parse_isbn_search_data(data)[:10]:
+ results.append(self.format_isbn_search_result(doc))
+ return results
+
+ def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use
+ """this allows connectors to override the default behavior"""
+ return get_data(remote_id, **kwargs)
+
@abstractmethod
def get_or_create_book(self, remote_id):
- ''' pull up a book record by whatever means possible '''
+ """pull up a book record by whatever means possible"""
@abstractmethod
def parse_search_data(self, data):
- ''' turn the result json from a search into a list '''
+ """turn the result json from a search into a list"""
@abstractmethod
def format_search_result(self, search_result):
- ''' create a SearchResult obj from json '''
+ """create a SearchResult obj from json"""
+
+ @abstractmethod
+ def parse_isbn_search_data(self, data):
+ """turn the result json from a search into a list"""
+
+ @abstractmethod
+ def format_isbn_search_result(self, search_result):
+ """create a SearchResult obj from json"""
class AbstractConnector(AbstractMinimalConnector):
- ''' generic book data connector '''
+ """generic book data connector"""
+
def __init__(self, identifier):
super().__init__(identifier)
# fields we want to look for in book data to copy over
# title we handle separately.
self.book_mappings = []
-
- def is_available(self):
- ''' check if you're allowed to use this connector '''
- if self.max_query_count is not None:
- if self.connector.query_count >= self.max_query_count:
- return False
- return True
-
-
def get_or_create_book(self, remote_id):
- ''' translate arbitrary json into an Activitypub dataclass '''
+ """translate arbitrary json into an Activitypub dataclass"""
# first, check if we have the origin_id saved
- existing = models.Edition.find_existing_by_remote_id(remote_id) or \
- models.Work.find_existing_by_remote_id(remote_id)
+ existing = models.Edition.find_existing_by_remote_id(
+ remote_id
+ ) or models.Work.find_existing_by_remote_id(remote_id)
if existing:
- if hasattr(existing, 'get_default_editon'):
- return existing.get_default_editon()
+ if hasattr(existing, "default_edition"):
+ return existing.default_edition
return existing
# load the json
- data = get_data(remote_id)
- mapped_data = dict_from_mappings(data, self.book_mappings)
+ data = self.get_book_data(remote_id)
if self.is_work_data(data):
try:
edition_data = self.get_edition_from_work_data(data)
@@ -111,44 +121,45 @@ class AbstractConnector(AbstractMinimalConnector):
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
- work_data = mapped_data
+ work_data = data
else:
+ edition_data = data
try:
work_data = self.get_work_from_edition_data(data)
- work_data = dict_from_mappings(work_data, self.book_mappings)
- except (KeyError, ConnectorException):
- work_data = mapped_data
- edition_data = data
+ except (KeyError, ConnectorException) as e:
+ logger.exception(e)
+ work_data = data
if not work_data or not edition_data:
- raise ConnectorException('Unable to load book data: %s' % remote_id)
+ raise ConnectorException("Unable to load book data: %s" % remote_id)
with transaction.atomic():
# create activitypub object
- work_activity = activitypub.Work(**work_data)
+ work_activity = activitypub.Work(
+ **dict_from_mappings(work_data, self.book_mappings)
+ )
# this will dedupe automatically
- work = work_activity.to_model(models.Work)
- for author in self.get_authors_from_data(data):
+ work = work_activity.to_model(model=models.Work)
+ for author in self.get_authors_from_data(work_data):
work.authors.add(author)
edition = self.create_edition_from_data(work, edition_data)
load_more_data.delay(self.connector.id, work.id)
return edition
+ def get_book_data(self, remote_id): # pylint: disable=no-self-use
+ """this allows connectors to override the default behavior"""
+ return get_data(remote_id)
def create_edition_from_data(self, work, edition_data):
- ''' if we already have the work, we're ready '''
+ """if we already have the work, we're ready"""
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
- mapped_data['work'] = work.remote_id
+ mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
- edition = edition_activity.to_model(models.Edition)
+ edition = edition_activity.to_model(model=models.Edition)
edition.connector = self.connector
edition.save()
- if not work.default_edition:
- work.default_edition = edition
- work.save()
-
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
if not edition.authors.exists() and work.authors.exists():
@@ -156,71 +167,80 @@ class AbstractConnector(AbstractMinimalConnector):
return edition
-
def get_or_create_author(self, remote_id):
- ''' load that author '''
+ """load that author"""
existing = models.Author.find_existing_by_remote_id(remote_id)
if existing:
return existing
- data = get_data(remote_id)
+ data = self.get_book_data(remote_id)
mapped_data = dict_from_mappings(data, self.author_mappings)
- activity = activitypub.Author(**mapped_data)
- # this will dedupe
- return activity.to_model(models.Author)
+ try:
+ activity = activitypub.Author(**mapped_data)
+ except activitypub.ActivitySerializerError:
+ return None
+ # this will dedupe
+ return activity.to_model(model=models.Author)
@abstractmethod
def is_work_data(self, data):
- ''' differentiate works and editions '''
+ """differentiate works and editions"""
@abstractmethod
def get_edition_from_work_data(self, data):
- ''' every work needs at least one edition '''
+ """every work needs at least one edition"""
@abstractmethod
def get_work_from_edition_data(self, data):
- ''' every edition needs a work '''
+ """every edition needs a work"""
@abstractmethod
def get_authors_from_data(self, data):
- ''' load author data '''
+ """load author data"""
@abstractmethod
def expand_book_data(self, book):
- ''' get more info on a book '''
+ """get more info on a book"""
def dict_from_mappings(data, mappings):
- ''' create a dict in Activitypub format, using mappings supplies by
- the subclass '''
+ """create a dict in Activitypub format, using mappings supplies by
+ the subclass"""
result = {}
for mapping in mappings:
+ # sometimes there are multiple mappings for one field, don't
+ # overwrite earlier writes in that case
+ if mapping.local_field in result and result[mapping.local_field]:
+ continue
result[mapping.local_field] = mapping.get_value(data)
return result
-def get_data(url):
- ''' wrapper for request.get '''
+def get_data(url, params=None):
+ """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:
resp = requests.get(
url,
+ params=params,
headers={
- 'Accept': 'application/json; charset=utf-8',
- 'User-Agent': settings.USER_AGENT,
+ "Accept": "application/json; charset=utf-8",
+ "User-Agent": settings.USER_AGENT,
},
)
- except (RequestError, SSLError) as e:
+ except (RequestError, SSLError, ConnectionError) as e:
logger.exception(e)
raise ConnectorException()
if not resp.ok:
- try:
- resp.raise_for_status()
- except requests.exceptions.HTTPError as e:
- logger.exception(e)
- raise ConnectorException()
+ raise ConnectorException()
try:
data = resp.json()
except ValueError as e:
@@ -231,12 +251,12 @@ def get_data(url):
def get_image(url):
- ''' wrapper for requesting an image '''
+ """wrapper for requesting an image"""
try:
resp = requests.get(
url,
headers={
- 'User-Agent': settings.USER_AGENT,
+ "User-Agent": settings.USER_AGENT,
},
)
except (RequestError, SSLError) as e:
@@ -249,27 +269,32 @@ def get_image(url):
@dataclass
class SearchResult:
- ''' standardized search result object '''
+ """standardized search result object"""
+
title: str
key: str
- author: str
- year: str
connector: object
+ view_link: str = None
+ author: str = None
+ year: str = None
+ cover: str = None
confidence: int = 1
def __repr__(self):
return "".format(
- self.key, self.title, self.author)
+ self.key, self.title, self.author
+ )
def json(self):
- ''' serialize a connector for json response '''
+ """serialize a connector for json response"""
serialized = asdict(self)
- del serialized['connector']
+ del serialized["connector"]
return serialized
class Mapping:
- ''' associate a local database field with a field in an external dataset '''
+ """associate a local database field with a field in an external dataset"""
+
def __init__(self, local_field, remote_field=None, formatter=None):
noop = lambda x: x
@@ -278,11 +303,11 @@ class Mapping:
self.formatter = formatter or noop
def get_value(self, data):
- ''' pull a field from incoming json and return the formatted version '''
+ """pull a field from incoming json and return the formatted version"""
value = data.get(self.remote_field)
if not value:
return None
try:
return self.formatter(value)
- except:# pylint: disable=bare-except
+ except: # pylint: disable=bare-except
return None
diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py
index 1f877993..10a633b2 100644
--- a/bookwyrm/connectors/bookwyrm_connector.py
+++ b/bookwyrm/connectors/bookwyrm_connector.py
@@ -1,21 +1,23 @@
-''' using another bookwyrm instance as a source of book data '''
+""" using another bookwyrm instance as a source of book data """
from bookwyrm import activitypub, models
from .abstract_connector import AbstractMinimalConnector, SearchResult
class Connector(AbstractMinimalConnector):
- ''' this is basically just for search '''
+ """this is basically just for search"""
def get_or_create_book(self, remote_id):
- edition = activitypub.resolve_remote_id(models.Edition, remote_id)
- work = edition.parent_work
- work.default_edition = work.get_default_edition()
- work.save()
- return edition
+ return activitypub.resolve_remote_id(remote_id, model=models.Edition)
def parse_search_data(self, data):
return data
def format_search_result(self, search_result):
- search_result['connector'] = self
+ search_result["connector"] = self
return SearchResult(**search_result)
+
+ def parse_isbn_search_data(self, data):
+ return data
+
+ def format_isbn_search_result(self, search_result):
+ return self.format_search_result(search_result)
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index a63a788e..95c5959d 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -1,79 +1,114 @@
-''' interface with whatever connectors the app has '''
+""" interface with whatever connectors the app has """
import importlib
+import logging
+import re
from urllib.parse import urlparse
+from django.dispatch import receiver
+from django.db.models import signals
+
from requests import HTTPError
from bookwyrm import models
from bookwyrm.tasks import app
+logger = logging.getLogger(__name__)
+
class ConnectorException(HTTPError):
- ''' when the connector can't do what was asked '''
+ """when the connector can't do what was asked"""
-def search(query, min_confidence=0.1):
- ''' find books based on arbitary keywords '''
+def search(query, min_confidence=0.1, return_first=False):
+ """find books based on arbitary keywords"""
+ if not query:
+ return []
results = []
- dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year)
- result_index = set()
- for connector in get_connectors():
- try:
- result_set = connector.search(query, min_confidence=min_confidence)
- except (HTTPError, ConnectorException):
- continue
- result_set = [r for r in result_set \
- if dedup_slug(r) not in result_index]
- # `|=` concats two sets. WE ARE GETTING FANCY HERE
- result_index |= set(dedup_slug(r) for r in result_set)
- results.append({
- 'connector': connector,
- 'results': result_set,
- })
+ # Have we got a ISBN ?
+ isbn = re.sub(r"[\W_]", "", query)
+ maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
+
+ for connector in get_connectors():
+ result_set = None
+ if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url == "":
+ # Search on ISBN
+ try:
+ result_set = connector.isbn_search(isbn)
+ except Exception as e: # pylint: disable=broad-except
+ logger.exception(e)
+ # if this fails, we can still try regular search
+
+ # if no isbn search results, we fallback to generic search
+ if not result_set:
+ try:
+ result_set = connector.search(query, min_confidence=min_confidence)
+ except Exception as e: # pylint: disable=broad-except
+ # we don't want *any* error to crash the whole search page
+ logger.exception(e)
+ continue
+
+ if return_first and result_set:
+ # if we found anything, return it
+ return result_set[0]
+
+ if result_set or connector.local:
+ results.append(
+ {
+ "connector": connector,
+ "results": result_set,
+ }
+ )
+
+ if return_first:
+ return None
return results
-def local_search(query, min_confidence=0.1, raw=False):
- ''' only look at local search results '''
+def local_search(query, min_confidence=0.1, raw=False, filters=None):
+ """only look at local search results"""
connector = load_connector(models.Connector.objects.get(local=True))
- return connector.search(query, min_confidence=min_confidence, raw=raw)
+ return connector.search(
+ query, min_confidence=min_confidence, raw=raw, filters=filters
+ )
+
+
+def isbn_local_search(query, raw=False):
+ """only look at local search results"""
+ connector = load_connector(models.Connector.objects.get(local=True))
+ return connector.isbn_search(query, raw=raw)
def first_search_result(query, min_confidence=0.1):
- ''' search until you find a result that fits '''
- for connector in get_connectors():
- result = connector.search(query, min_confidence=min_confidence)
- if result:
- return result[0]
- return None
+ """search until you find a result that fits"""
+ return search(query, min_confidence=min_confidence, return_first=True) or None
def get_connectors():
- ''' load all connectors '''
- for info in models.Connector.objects.order_by('priority').all():
+ """load all connectors"""
+ for info in models.Connector.objects.filter(active=True).order_by("priority").all():
yield load_connector(info)
def get_or_create_connector(remote_id):
- ''' get the connector related to the author's server '''
+ """get the connector related to the object's server"""
url = urlparse(remote_id)
identifier = url.netloc
if not identifier:
- raise ValueError('Invalid remote id')
+ raise ValueError("Invalid remote id")
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
- connector_file='bookwyrm_connector',
- base_url='https://%s' % identifier,
- books_url='https://%s/book' % identifier,
- covers_url='https://%s/images/covers' % identifier,
- search_url='https://%s/search?q=' % identifier,
- priority=2
+ connector_file="bookwyrm_connector",
+ base_url="https://%s" % identifier,
+ books_url="https://%s/book" % identifier,
+ covers_url="https://%s/images/covers" % identifier,
+ search_url="https://%s/search?q=" % identifier,
+ priority=2,
)
return load_connector(connector_info)
@@ -81,7 +116,7 @@ def get_or_create_connector(remote_id):
@app.task
def load_more_data(connector_id, book_id):
- ''' background the work of getting all 10,000 editions of LoTR '''
+ """background the work of getting all 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
book = models.Book.objects.select_subclasses().get(id=book_id)
@@ -89,8 +124,16 @@ def load_more_data(connector_id, book_id):
def load_connector(connector_info):
- ''' instantiate the connector class '''
+ """instantiate the connector class"""
connector = importlib.import_module(
- 'bookwyrm.connectors.%s' % connector_info.connector_file
+ "bookwyrm.connectors.%s" % connector_info.connector_file
)
return connector.Connector(connector_info.identifier)
+
+
+@receiver(signals.post_save, sender="bookwyrm.FederatedServer")
+# pylint: disable=unused-argument
+def create_connector(sender, instance, created, *args, **kwargs):
+ """create a connector to an external bookwyrm server"""
+ if instance.application_type == "bookwyrm":
+ get_or_create_connector("https://{:s}".format(instance.server_name))
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
new file mode 100644
index 00000000..102c9d72
--- /dev/null
+++ b/bookwyrm/connectors/inventaire.py
@@ -0,0 +1,232 @@
+""" inventaire data connector """
+import re
+
+from bookwyrm import models
+from .abstract_connector import AbstractConnector, SearchResult, Mapping
+from .abstract_connector import get_data
+from .connector_manager import ConnectorException
+
+
+class Connector(AbstractConnector):
+ """instantiate a connector for OL"""
+
+ def __init__(self, identifier):
+ super().__init__(identifier)
+
+ get_first = lambda a: a[0]
+ shared_mappings = [
+ Mapping("id", remote_field="uri", formatter=self.get_remote_id),
+ Mapping("bnfId", remote_field="wdt:P268", formatter=get_first),
+ Mapping("openlibraryKey", remote_field="wdt:P648", formatter=get_first),
+ ]
+ self.book_mappings = [
+ Mapping("title", remote_field="wdt:P1476", formatter=get_first),
+ Mapping("title", remote_field="labels", formatter=get_language_code),
+ Mapping("subtitle", remote_field="wdt:P1680", formatter=get_first),
+ Mapping("inventaireId", remote_field="uri"),
+ Mapping(
+ "description", remote_field="sitelinks", formatter=self.get_description
+ ),
+ Mapping("cover", remote_field="image", formatter=self.get_cover_url),
+ Mapping("isbn13", remote_field="wdt:P212", formatter=get_first),
+ Mapping("isbn10", remote_field="wdt:P957", formatter=get_first),
+ Mapping("oclcNumber", remote_field="wdt:P5331", formatter=get_first),
+ Mapping("goodreadsKey", remote_field="wdt:P2969", formatter=get_first),
+ Mapping("librarythingKey", remote_field="wdt:P1085", formatter=get_first),
+ Mapping("languages", remote_field="wdt:P407", formatter=self.resolve_keys),
+ Mapping("publishers", remote_field="wdt:P123", formatter=self.resolve_keys),
+ Mapping("publishedDate", remote_field="wdt:P577", formatter=get_first),
+ Mapping("pages", remote_field="wdt:P1104", formatter=get_first),
+ Mapping(
+ "subjectPlaces", remote_field="wdt:P840", formatter=self.resolve_keys
+ ),
+ Mapping("subjects", remote_field="wdt:P921", formatter=self.resolve_keys),
+ Mapping("asin", remote_field="wdt:P5749", formatter=get_first),
+ ] + shared_mappings
+ # TODO: P136: genre, P674 characters, P950 bne
+
+ self.author_mappings = [
+ Mapping("id", remote_field="uri", formatter=self.get_remote_id),
+ Mapping("name", remote_field="labels", formatter=get_language_code),
+ Mapping("bio", remote_field="sitelinks", formatter=self.get_description),
+ Mapping("goodreadsKey", remote_field="wdt:P2963", formatter=get_first),
+ Mapping("isni", remote_field="wdt:P213", formatter=get_first),
+ Mapping("viafId", remote_field="wdt:P214", formatter=get_first),
+ Mapping("gutenberg_id", remote_field="wdt:P1938", formatter=get_first),
+ Mapping("born", remote_field="wdt:P569", formatter=get_first),
+ Mapping("died", remote_field="wdt:P570", formatter=get_first),
+ ] + shared_mappings
+
+ def get_remote_id(self, value):
+ """convert an id/uri into a url"""
+ return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value)
+
+ def get_book_data(self, remote_id):
+ data = get_data(remote_id)
+ extracted = list(data.get("entities").values())
+ try:
+ data = extracted[0]
+ except KeyError:
+ raise ConnectorException("Invalid book data")
+ # flatten the data so that images, uri, and claims are on the same level
+ return {
+ **data.get("claims", {}),
+ **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]},
+ }
+
+ def search(self, query, min_confidence=None):
+ """overrides default search function with confidence ranking"""
+ results = super().search(query)
+ if min_confidence:
+ # filter the search results after the fact
+ return [r for r in results if r.confidence >= min_confidence]
+ return results
+
+ def parse_search_data(self, data):
+ return data.get("results")
+
+ def format_search_result(self, search_result):
+ images = search_result.get("image")
+ cover = (
+ "{:s}/img/entities/{:s}".format(self.covers_url, images[0])
+ if images
+ else None
+ )
+ # a deeply messy translation of inventaire's scores
+ confidence = float(search_result.get("_score", 0.1))
+ confidence = 0.1 if confidence < 150 else 0.999
+ return SearchResult(
+ title=search_result.get("label"),
+ key=self.get_remote_id(search_result.get("uri")),
+ author=search_result.get("description"),
+ view_link="{:s}/entity/{:s}".format(
+ self.base_url, search_result.get("uri")
+ ),
+ cover=cover,
+ confidence=confidence,
+ connector=self,
+ )
+
+ def parse_isbn_search_data(self, data):
+ """got some daaaata"""
+ results = data.get("entities")
+ if not results:
+ return []
+ return list(results.values())
+
+ def format_isbn_search_result(self, search_result):
+ """totally different format than a regular search result"""
+ title = search_result.get("claims", {}).get("wdt:P1476", [])
+ if not title:
+ return None
+ return SearchResult(
+ title=title[0],
+ key=self.get_remote_id(search_result.get("uri")),
+ author=search_result.get("description"),
+ view_link="{:s}/entity/{:s}".format(
+ self.base_url, search_result.get("uri")
+ ),
+ cover=self.get_cover_url(search_result.get("image")),
+ connector=self,
+ )
+
+ def is_work_data(self, data):
+ return data.get("type") == "work"
+
+ def load_edition_data(self, work_uri):
+ """get a list of editions for a work"""
+ url = (
+ "{:s}?action=reverse-claims&property=wdt:P629&value={:s}&sort=true".format(
+ self.books_url, work_uri
+ )
+ )
+ return get_data(url)
+
+ def get_edition_from_work_data(self, data):
+ data = self.load_edition_data(data.get("uri"))
+ try:
+ uri = data["uris"][0]
+ except KeyError:
+ raise ConnectorException("Invalid book data")
+ return self.get_book_data(self.get_remote_id(uri))
+
+ def get_work_from_edition_data(self, data):
+ uri = data.get("wdt:P629", [None])[0]
+ if not uri:
+ raise ConnectorException("Invalid book data")
+ return self.get_book_data(self.get_remote_id(uri))
+
+ def get_authors_from_data(self, data):
+ authors = data.get("wdt:P50", [])
+ for author in authors:
+ yield self.get_or_create_author(self.get_remote_id(author))
+
+ def expand_book_data(self, book):
+ work = book
+ # go from the edition to the work, if necessary
+ if isinstance(book, models.Edition):
+ work = book.parent_work
+
+ try:
+ edition_options = self.load_edition_data(work.inventaire_id)
+ except ConnectorException:
+ # who knows, man
+ return
+
+ for edition_uri in edition_options.get("uris"):
+ remote_id = self.get_remote_id(edition_uri)
+ try:
+ data = self.get_book_data(remote_id)
+ except ConnectorException:
+ # who, indeed, knows
+ continue
+ self.create_edition_from_data(work, data)
+
+ def get_cover_url(self, cover_blob, *_):
+ """format the relative cover url into an absolute one:
+ {"url": "/img/entities/e794783f01b9d4f897a1ea9820b96e00d346994f"}
+ """
+ # covers may or may not be a list
+ if isinstance(cover_blob, list) and len(cover_blob) > 0:
+ cover_blob = cover_blob[0]
+ cover_id = cover_blob.get("url")
+ if not cover_id:
+ return None
+ # cover may or may not be an absolute url already
+ if re.match(r"^http", cover_id):
+ return cover_id
+ return "%s%s" % (self.covers_url, cover_id)
+
+ def resolve_keys(self, keys):
+ """cool, it's "wd:Q3156592" now what the heck does that mean"""
+ results = []
+ for uri in keys:
+ try:
+ data = self.get_book_data(self.get_remote_id(uri))
+ except ConnectorException:
+ continue
+ results.append(get_language_code(data.get("labels")))
+ return results
+
+ def get_description(self, links):
+ """grab an extracted excerpt from wikipedia"""
+ link = links.get("enwiki")
+ if not link:
+ return ""
+ url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format(
+ self.base_url, link
+ )
+ try:
+ data = get_data(url)
+ except ConnectorException:
+ return ""
+ return data.get("extract")
+
+
+def get_language_code(options, code="en"):
+ """when there are a bunch of translation but we need a single field"""
+ result = options.get(code)
+ if result:
+ return result
+ values = list(options.values())
+ return values[0] if values else None
diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
index a767a45a..e58749c1 100644
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -1,4 +1,4 @@
-''' openlibrary data connector '''
+""" openlibrary data connector """
import re
from bookwyrm import models
@@ -9,131 +9,151 @@ from .openlibrary_languages import languages
class Connector(AbstractConnector):
- ''' instantiate a connector for OL '''
+ """instantiate a connector for OL"""
+
def __init__(self, identifier):
super().__init__(identifier)
- get_first = lambda a: a[0]
- get_remote_id = lambda a: self.base_url + a
+ get_first = lambda a, *args: a[0]
+ get_remote_id = lambda a, *args: self.base_url + a
self.book_mappings = [
- Mapping('title'),
- Mapping('id', remote_field='key', formatter=get_remote_id),
+ Mapping("title"),
+ Mapping("id", remote_field="key", formatter=get_remote_id),
+ Mapping("cover", remote_field="covers", formatter=self.get_cover_url),
+ Mapping("sortTitle", remote_field="sort_title"),
+ Mapping("subtitle"),
+ Mapping("description", formatter=get_description),
+ Mapping("languages", formatter=get_languages),
+ Mapping("series", formatter=get_first),
+ Mapping("seriesNumber", remote_field="series_number"),
+ Mapping("subjects"),
+ Mapping("subjectPlaces", remote_field="subject_places"),
+ Mapping("isbn13", remote_field="isbn_13", formatter=get_first),
+ Mapping("isbn10", remote_field="isbn_10", formatter=get_first),
+ Mapping("lccn", formatter=get_first),
+ Mapping("oclcNumber", remote_field="oclc_numbers", formatter=get_first),
Mapping(
- 'cover', remote_field='covers', formatter=self.get_cover_url),
- Mapping('sortTitle', remote_field='sort_title'),
- Mapping('subtitle'),
- Mapping('description', formatter=get_description),
- Mapping('languages', formatter=get_languages),
- Mapping('series', formatter=get_first),
- Mapping('seriesNumber', remote_field='series_number'),
- Mapping('subjects'),
- Mapping('subjectPlaces', remote_field='subject_places'),
- Mapping('isbn13', remote_field='isbn_13', formatter=get_first),
- Mapping('isbn10', remote_field='isbn_10', formatter=get_first),
- Mapping('lccn', formatter=get_first),
- Mapping(
- 'oclcNumber', remote_field='oclc_numbers',
- formatter=get_first
+ "openlibraryKey", remote_field="key", formatter=get_openlibrary_key
),
+ Mapping("goodreadsKey", remote_field="goodreads_key"),
+ Mapping("asin"),
Mapping(
- 'openlibraryKey', remote_field='key',
- formatter=get_openlibrary_key
+ "firstPublishedDate",
+ remote_field="first_publish_date",
),
- Mapping('goodreadsKey', remote_field='goodreads_key'),
- Mapping('asin'),
- Mapping(
- 'firstPublishedDate', remote_field='first_publish_date',
- ),
- Mapping('publishedDate', remote_field='publish_date'),
- Mapping('pages', remote_field='number_of_pages'),
- Mapping('physicalFormat', remote_field='physical_format'),
- Mapping('publishers'),
+ Mapping("publishedDate", remote_field="publish_date"),
+ Mapping("pages", remote_field="number_of_pages"),
+ Mapping("physicalFormat", remote_field="physical_format"),
+ Mapping("publishers"),
]
self.author_mappings = [
- Mapping('id', remote_field='key', formatter=get_remote_id),
- Mapping('name'),
+ Mapping("id", remote_field="key", formatter=get_remote_id),
+ Mapping("name"),
Mapping(
- 'openlibraryKey', remote_field='key',
- formatter=get_openlibrary_key
+ "openlibraryKey", remote_field="key", formatter=get_openlibrary_key
),
- Mapping('born', remote_field='birth_date'),
- Mapping('died', remote_field='death_date'),
- Mapping('bio', formatter=get_description),
+ Mapping("born", remote_field="birth_date"),
+ Mapping("died", remote_field="death_date"),
+ Mapping("bio", formatter=get_description),
]
+ def get_book_data(self, remote_id):
+ data = get_data(remote_id)
+ if data.get("type", {}).get("key") == "/type/redirect":
+ remote_id = self.base_url + data.get("location")
+ return get_data(remote_id)
+ return data
def get_remote_id_from_data(self, data):
- ''' format a url from an openlibrary id field '''
+ """format a url from an openlibrary id field"""
try:
- key = data['key']
+ key = data["key"]
except KeyError:
- raise ConnectorException('Invalid book data')
- return '%s%s' % (self.books_url, key)
-
+ raise ConnectorException("Invalid book data")
+ return "%s%s" % (self.books_url, key)
def is_work_data(self, data):
- return bool(re.match(r'^[\/\w]+OL\d+W$', data['key']))
-
+ return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
def get_edition_from_work_data(self, data):
try:
- key = data['key']
+ key = data["key"]
except KeyError:
- raise ConnectorException('Invalid book data')
- url = '%s%s/editions' % (self.books_url, key)
- data = get_data(url)
- return pick_default_edition(data['entries'])
-
+ raise ConnectorException("Invalid book data")
+ url = "%s%s/editions" % (self.books_url, key)
+ data = self.get_book_data(url)
+ edition = pick_default_edition(data["entries"])
+ if not edition:
+ raise ConnectorException("No editions for work")
+ return edition
def get_work_from_edition_data(self, data):
try:
- key = data['works'][0]['key']
+ key = data["works"][0]["key"]
except (IndexError, KeyError):
- raise ConnectorException('No work found for edition')
- url = '%s%s' % (self.books_url, key)
- return get_data(url)
-
+ raise ConnectorException("No work found for edition")
+ url = "%s%s" % (self.books_url, key)
+ return self.get_book_data(url)
def get_authors_from_data(self, data):
- ''' parse author json and load or create authors '''
- for author_blob in data.get('authors', []):
- author_blob = author_blob.get('author', author_blob)
+ """parse author json and load or create authors"""
+ for author_blob in data.get("authors", []):
+ author_blob = author_blob.get("author", author_blob)
# this id is "/authors/OL1234567A"
- author_id = author_blob['key']
- url = '%s%s' % (self.base_url, author_id)
- yield self.get_or_create_author(url)
+ author_id = author_blob["key"]
+ url = "%s%s" % (self.base_url, author_id)
+ author = self.get_or_create_author(url)
+ if not author:
+ continue
+ yield author
-
- def get_cover_url(self, cover_blob):
- ''' ask openlibrary for the cover '''
+ def get_cover_url(self, cover_blob, size="L"):
+ """ask openlibrary for the cover"""
+ if not cover_blob:
+ return None
cover_id = cover_blob[0]
- image_name = '%s-L.jpg' % cover_id
- return '%s/b/id/%s' % (self.covers_url, image_name)
-
+ image_name = "%s-%s.jpg" % (cover_id, size)
+ return "%s/b/id/%s" % (self.covers_url, image_name)
def parse_search_data(self, data):
- return data.get('docs')
-
+ return data.get("docs")
def format_search_result(self, search_result):
# build the remote id from the openlibrary key
- key = self.books_url + search_result['key']
- author = search_result.get('author_name') or ['Unknown']
+ key = self.books_url + search_result["key"]
+ author = search_result.get("author_name") or ["Unknown"]
+ cover_blob = search_result.get("cover_i")
+ cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
return SearchResult(
- title=search_result.get('title'),
+ title=search_result.get("title"),
key=key,
- author=', '.join(author),
+ author=", ".join(author),
connector=self,
- year=search_result.get('first_publish_year'),
+ year=search_result.get("first_publish_year"),
+ cover=cover,
)
+ def parse_isbn_search_data(self, data):
+ return list(data.values())
+
+ def format_isbn_search_result(self, search_result):
+ # build the remote id from the openlibrary key
+ key = self.books_url + search_result["key"]
+ authors = search_result.get("authors") or [{"name": "Unknown"}]
+ author_names = [author.get("name") for author in authors]
+ return SearchResult(
+ title=search_result.get("title"),
+ key=key,
+ author=", ".join(author_names),
+ connector=self,
+ year=search_result.get("publish_date"),
+ )
def load_edition_data(self, olkey):
- ''' query openlibrary for editions of a work '''
- url = '%s/works/%s/editions' % (self.books_url, olkey)
- return get_data(url)
-
+ """query openlibrary for editions of a work"""
+ url = "%s/works/%s/editions" % (self.books_url, olkey)
+ return self.get_book_data(url)
def expand_book_data(self, book):
work = book
@@ -148,7 +168,7 @@ class Connector(AbstractConnector):
# who knows, man
return
- for edition_data in edition_options.get('entries'):
+ for edition_data in edition_options.get("entries"):
# does this edition have ANY interesting data?
if ignore_edition(edition_data):
continue
@@ -156,62 +176,59 @@ class Connector(AbstractConnector):
def ignore_edition(edition_data):
- ''' don't load a million editions that have no metadata '''
+ """don't load a million editions that have no metadata"""
# an isbn, we love to see it
- if edition_data.get('isbn_13') or edition_data.get('isbn_10'):
- print(edition_data.get('isbn_10'))
+ if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
return False
# grudgingly, oclc can stay
- if edition_data.get('oclc_numbers'):
- print(edition_data.get('oclc_numbers'))
+ if edition_data.get("oclc_numbers"):
return False
# if it has a cover it can stay
- if edition_data.get('covers'):
- print(edition_data.get('covers'))
+ if edition_data.get("covers"):
return False
# keep non-english editions
- if edition_data.get('languages') and \
- 'languages/eng' not in str(edition_data.get('languages')):
- print(edition_data.get('languages'))
+ if edition_data.get("languages") and "languages/eng" not in str(
+ edition_data.get("languages")
+ ):
return False
return True
def get_description(description_blob):
- ''' descriptions can be a string or a dict '''
+ """descriptions can be a string or a dict"""
if isinstance(description_blob, dict):
- return description_blob.get('value')
+ return description_blob.get("value")
return description_blob
def get_openlibrary_key(key):
- ''' convert /books/OL27320736M into OL27320736M '''
- return key.split('/')[-1]
+ """convert /books/OL27320736M into OL27320736M"""
+ return key.split("/")[-1]
def get_languages(language_blob):
- ''' /language/eng -> English '''
+ """/language/eng -> English"""
langs = []
for lang in language_blob:
- langs.append(
- languages.get(lang.get('key', ''), None)
- )
+ langs.append(languages.get(lang.get("key", ""), None))
return langs
def pick_default_edition(options):
- ''' favor physical copies with covers in english '''
+ """favor physical copies with covers in english"""
if not options:
return None
if len(options) == 1:
return options[0]
- options = [e for e in options if e.get('covers')] or options
- options = [e for e in options if \
- '/languages/eng' in str(e.get('languages'))] or options
- formats = ['paperback', 'hardcover', 'mass market paperback']
- options = [e for e in options if \
- str(e.get('physical_format')).lower() in formats] or options
- options = [e for e in options if e.get('isbn_13')] or options
- options = [e for e in options if e.get('ocaid')] or options
+ options = [e for e in options if e.get("covers")] or options
+ options = [
+ e for e in options if "/languages/eng" in str(e.get("languages"))
+ ] or options
+ formats = ["paperback", "hardcover", "mass market paperback"]
+ options = [
+ e for e in options if str(e.get("physical_format")).lower() in formats
+ ] or options
+ options = [e for e in options if e.get("isbn_13")] or options
+ options = [e for e in options if e.get("ocaid")] or options
return options[0]
diff --git a/bookwyrm/connectors/openlibrary_languages.py b/bookwyrm/connectors/openlibrary_languages.py
index b687f8b9..2520d1ea 100644
--- a/bookwyrm/connectors/openlibrary_languages.py
+++ b/bookwyrm/connectors/openlibrary_languages.py
@@ -1,467 +1,467 @@
-''' key lookups for openlibrary languages '''
+""" key lookups for openlibrary languages """
languages = {
- '/languages/eng': 'English',
- '/languages/fre': 'French',
- '/languages/spa': 'Spanish',
- '/languages/ger': 'German',
- '/languages/rus': 'Russian',
- '/languages/ita': 'Italian',
- '/languages/chi': 'Chinese',
- '/languages/jpn': 'Japanese',
- '/languages/por': 'Portuguese',
- '/languages/ara': 'Arabic',
- '/languages/pol': 'Polish',
- '/languages/heb': 'Hebrew',
- '/languages/kor': 'Korean',
- '/languages/dut': 'Dutch',
- '/languages/ind': 'Indonesian',
- '/languages/lat': 'Latin',
- '/languages/und': 'Undetermined',
- '/languages/cmn': 'Mandarin',
- '/languages/hin': 'Hindi',
- '/languages/swe': 'Swedish',
- '/languages/dan': 'Danish',
- '/languages/urd': 'Urdu',
- '/languages/hun': 'Hungarian',
- '/languages/cze': 'Czech',
- '/languages/tur': 'Turkish',
- '/languages/ukr': 'Ukrainian',
- '/languages/gre': 'Greek',
- '/languages/vie': 'Vietnamese',
- '/languages/bul': 'Bulgarian',
- '/languages/ben': 'Bengali',
- '/languages/rum': 'Romanian',
- '/languages/cat': 'Catalan',
- '/languages/nor': 'Norwegian',
- '/languages/tha': 'Thai',
- '/languages/per': 'Persian',
- '/languages/scr': 'Croatian',
- '/languages/mul': 'Multiple languages',
- '/languages/fin': 'Finnish',
- '/languages/tam': 'Tamil',
- '/languages/guj': 'Gujarati',
- '/languages/mar': 'Marathi',
- '/languages/scc': 'Serbian',
- '/languages/pan': 'Panjabi',
- '/languages/wel': 'Welsh',
- '/languages/tel': 'Telugu',
- '/languages/yid': 'Yiddish',
- '/languages/kan': 'Kannada',
- '/languages/slo': 'Slovak',
- '/languages/san': 'Sanskrit',
- '/languages/arm': 'Armenian',
- '/languages/mal': 'Malayalam',
- '/languages/may': 'Malay',
- '/languages/bur': 'Burmese',
- '/languages/slv': 'Slovenian',
- '/languages/lit': 'Lithuanian',
- '/languages/tib': 'Tibetan',
- '/languages/lav': 'Latvian',
- '/languages/est': 'Estonian',
- '/languages/nep': 'Nepali',
- '/languages/ori': 'Oriya',
- '/languages/mon': 'Mongolian',
- '/languages/alb': 'Albanian',
- '/languages/iri': 'Irish',
- '/languages/geo': 'Georgian',
- '/languages/afr': 'Afrikaans',
- '/languages/grc': 'Ancient Greek',
- '/languages/mac': 'Macedonian',
- '/languages/bel': 'Belarusian',
- '/languages/ice': 'Icelandic',
- '/languages/srp': 'Serbian',
- '/languages/snh': 'Sinhalese',
- '/languages/snd': 'Sindhi',
- '/languages/ota': 'Turkish, Ottoman',
- '/languages/kur': 'Kurdish',
- '/languages/aze': 'Azerbaijani',
- '/languages/pus': 'Pushto',
- '/languages/amh': 'Amharic',
- '/languages/gag': 'Galician',
- '/languages/hrv': 'Croatian',
- '/languages/sin': 'Sinhalese',
- '/languages/asm': 'Assamese',
- '/languages/uzb': 'Uzbek',
- '/languages/gae': 'Scottish Gaelix',
- '/languages/kaz': 'Kazakh',
- '/languages/swa': 'Swahili',
- '/languages/bos': 'Bosnian',
- '/languages/glg': 'Galician ',
- '/languages/baq': 'Basque',
- '/languages/tgl': 'Tagalog',
- '/languages/raj': 'Rajasthani',
- '/languages/gle': 'Irish',
- '/languages/lao': 'Lao',
- '/languages/jav': 'Javanese',
- '/languages/mai': 'Maithili',
- '/languages/tgk': 'Tajik ',
- '/languages/khm': 'Khmer',
- '/languages/roh': 'Raeto-Romance',
- '/languages/kok': 'Konkani ',
- '/languages/sit': 'Sino-Tibetan (Other)',
- '/languages/mol': 'Moldavian',
- '/languages/kir': 'Kyrgyz',
- '/languages/new': 'Newari',
- '/languages/inc': 'Indic (Other)',
- '/languages/frm': 'French, Middle (ca. 1300-1600)',
- '/languages/esp': 'Esperanto',
- '/languages/hau': 'Hausa',
- '/languages/tag': 'Tagalog',
- '/languages/tuk': 'Turkmen',
- '/languages/enm': 'English, Middle (1100-1500)',
- '/languages/map': 'Austronesian (Other)',
- '/languages/pli': 'Pali',
- '/languages/fro': 'French, Old (ca. 842-1300)',
- '/languages/nic': 'Niger-Kordofanian (Other)',
- '/languages/tir': 'Tigrinya',
- '/languages/wen': 'Sorbian (Other)',
- '/languages/bho': 'Bhojpuri',
- '/languages/roa': 'Romance (Other)',
- '/languages/tut': 'Altaic (Other)',
- '/languages/bra': 'Braj',
- '/languages/sun': 'Sundanese',
- '/languages/fiu': 'Finno-Ugrian (Other)',
- '/languages/far': 'Faroese',
- '/languages/ban': 'Balinese',
- '/languages/tar': 'Tatar',
- '/languages/bak': 'Bashkir',
- '/languages/tat': 'Tatar',
- '/languages/chu': 'Church Slavic',
- '/languages/dra': 'Dravidian (Other)',
- '/languages/pra': 'Prakrit languages',
- '/languages/paa': 'Papuan (Other)',
- '/languages/doi': 'Dogri',
- '/languages/lah': 'LahndÄ',
- '/languages/mni': 'Manipuri',
- '/languages/yor': 'Yoruba',
- '/languages/gmh': 'German, Middle High (ca. 1050-1500)',
- '/languages/kas': 'Kashmiri',
- '/languages/fri': 'Frisian',
- '/languages/mla': 'Malagasy',
- '/languages/egy': 'Egyptian',
- '/languages/rom': 'Romani',
- '/languages/syr': 'Syriac, Modern',
- '/languages/cau': 'Caucasian (Other)',
- '/languages/hbs': 'Serbo-Croatian',
- '/languages/sai': 'South American Indian (Other)',
- '/languages/pro': 'Provençal (to 1500)',
- '/languages/cpf': 'Creoles and Pidgins, French-based (Other)',
- '/languages/ang': 'English, Old (ca. 450-1100)',
- '/languages/bal': 'Baluchi',
- '/languages/gla': 'Scottish Gaelic',
- '/languages/chv': 'Chuvash',
- '/languages/kin': 'Kinyarwanda',
- '/languages/zul': 'Zulu',
- '/languages/sla': 'Slavic (Other)',
- '/languages/som': 'Somali',
- '/languages/mlt': 'Maltese',
- '/languages/uig': 'Uighur',
- '/languages/mlg': 'Malagasy',
- '/languages/sho': 'Shona',
- '/languages/lan': 'Occitan (post 1500)',
- '/languages/bre': 'Breton',
- '/languages/sco': 'Scots',
- '/languages/sso': 'Sotho',
- '/languages/myn': 'Mayan languages',
- '/languages/xho': 'Xhosa',
- '/languages/gem': 'Germanic (Other)',
- '/languages/esk': 'Eskimo languages',
- '/languages/akk': 'Akkadian',
- '/languages/div': 'Maldivian',
- '/languages/sah': 'Yakut',
- '/languages/tsw': 'Tswana',
- '/languages/nso': 'Northern Sotho',
- '/languages/pap': 'Papiamento',
- '/languages/bnt': 'Bantu (Other)',
- '/languages/oss': 'Ossetic',
- '/languages/cre': 'Cree',
- '/languages/ibo': 'Igbo',
- '/languages/fao': 'Faroese',
- '/languages/nai': 'North American Indian (Other)',
- '/languages/mag': 'Magahi',
- '/languages/arc': 'Aramaic',
- '/languages/epo': 'Esperanto',
- '/languages/kha': 'Khasi',
- '/languages/oji': 'Ojibwa',
- '/languages/que': 'Quechua',
- '/languages/lug': 'Ganda',
- '/languages/mwr': 'Marwari',
- '/languages/awa': 'Awadhi ',
- '/languages/cor': 'Cornish',
- '/languages/lad': 'Ladino',
- '/languages/dzo': 'Dzongkha',
- '/languages/cop': 'Coptic',
- '/languages/nah': 'Nahuatl',
- '/languages/cai': 'Central American Indian (Other)',
- '/languages/phi': 'Philippine (Other)',
- '/languages/moh': 'Mohawk',
- '/languages/crp': 'Creoles and Pidgins (Other)',
- '/languages/nya': 'Nyanja',
- '/languages/wol': 'Wolof ',
- '/languages/haw': 'Hawaiian',
- '/languages/eth': 'Ethiopic',
- '/languages/mis': 'Miscellaneous languages',
- '/languages/mkh': 'Mon-Khmer (Other)',
- '/languages/alg': 'Algonquian (Other)',
- '/languages/nde': 'Ndebele (Zimbabwe)',
- '/languages/ssa': 'Nilo-Saharan (Other)',
- '/languages/chm': 'Mari',
- '/languages/che': 'Chechen',
- '/languages/gez': 'Ethiopic',
- '/languages/ven': 'Venda',
- '/languages/cam': 'Khmer',
- '/languages/fur': 'Friulian',
- '/languages/ful': 'Fula',
- '/languages/gal': 'Oromo',
- '/languages/jrb': 'Judeo-Arabic',
- '/languages/bua': 'Buriat',
- '/languages/ady': 'Adygei',
- '/languages/bem': 'Bemba',
- '/languages/kar': 'Karen languages',
- '/languages/sna': 'Shona',
- '/languages/twi': 'Twi',
- '/languages/btk': 'Batak',
- '/languages/kaa': 'Kara-Kalpak',
- '/languages/kom': 'Komi',
- '/languages/sot': 'Sotho',
- '/languages/tso': 'Tsonga',
- '/languages/cpe': 'Creoles and Pidgins, English-based (Other)',
- '/languages/gua': 'Guarani',
- '/languages/mao': 'Maori',
- '/languages/mic': 'Micmac',
- '/languages/swz': 'Swazi',
- '/languages/taj': 'Tajik',
- '/languages/smo': 'Samoan',
- '/languages/ace': 'Achinese',
- '/languages/afa': 'Afroasiatic (Other)',
- '/languages/lap': 'Sami',
- '/languages/min': 'Minangkabau',
- '/languages/oci': 'Occitan (post 1500)',
- '/languages/tsn': 'Tswana',
- '/languages/pal': 'Pahlavi',
- '/languages/sux': 'Sumerian',
- '/languages/ewe': 'Ewe',
- '/languages/him': 'Himachali',
- '/languages/kaw': 'Kawi',
- '/languages/lus': 'Lushai',
- '/languages/ceb': 'Cebuano',
- '/languages/chr': 'Cherokee',
- '/languages/fil': 'Filipino',
- '/languages/ndo': 'Ndonga',
- '/languages/ilo': 'Iloko',
- '/languages/kbd': 'Kabardian',
- '/languages/orm': 'Oromo',
- '/languages/dum': 'Dutch, Middle (ca. 1050-1350)',
- '/languages/bam': 'Bambara',
- '/languages/goh': 'Old High German',
- '/languages/got': 'Gothic',
- '/languages/kon': 'Kongo',
- '/languages/mun': 'Munda (Other)',
- '/languages/kru': 'Kurukh',
- '/languages/pam': 'Pampanga',
- '/languages/grn': 'Guarani',
- '/languages/gaa': 'Gã',
- '/languages/fry': 'Frisian',
- '/languages/iba': 'Iban',
- '/languages/mak': 'Makasar',
- '/languages/kik': 'Kikuyu',
- '/languages/cho': 'Choctaw',
- '/languages/cpp': 'Creoles and Pidgins, Portuguese-based (Other)',
- '/languages/dak': 'Dakota',
- '/languages/udm': 'Udmurt ',
- '/languages/hat': 'Haitian French Creole',
- '/languages/mus': 'Creek',
- '/languages/ber': 'Berber (Other)',
- '/languages/hil': 'Hiligaynon',
- '/languages/iro': 'Iroquoian (Other)',
- '/languages/kua': 'Kuanyama',
- '/languages/mno': 'Manobo languages',
- '/languages/run': 'Rundi',
- '/languages/sat': 'Santali',
- '/languages/shn': 'Shan',
- '/languages/tyv': 'Tuvinian',
- '/languages/chg': 'Chagatai',
- '/languages/syc': 'Syriac',
- '/languages/ath': 'Athapascan (Other)',
- '/languages/aym': 'Aymara',
- '/languages/bug': 'Bugis',
- '/languages/cel': 'Celtic (Other)',
- '/languages/int': 'Interlingua (International Auxiliary Language Association)',
- '/languages/xal': 'Oirat',
- '/languages/ava': 'Avaric',
- '/languages/son': 'Songhai',
- '/languages/tah': 'Tahitian',
- '/languages/tet': 'Tetum',
- '/languages/ira': 'Iranian (Other)',
- '/languages/kac': 'Kachin',
- '/languages/nob': 'Norwegian (Bokmål)',
- '/languages/vai': 'Vai',
- '/languages/bik': 'Bikol',
- '/languages/mos': 'Mooré',
- '/languages/tig': 'Tigré',
- '/languages/fat': 'Fanti',
- '/languages/her': 'Herero',
- '/languages/kal': 'Kalâtdlisut',
- '/languages/mad': 'Madurese',
- '/languages/yue': 'Cantonese',
- '/languages/chn': 'Chinook jargon',
- '/languages/hmn': 'Hmong',
- '/languages/lin': 'Lingala',
- '/languages/man': 'Mandingo',
- '/languages/nds': 'Low German',
- '/languages/bas': 'Basa',
- '/languages/gay': 'Gayo',
- '/languages/gsw': 'gsw',
- '/languages/ine': 'Indo-European (Other)',
- '/languages/kro': 'Kru (Other)',
- '/languages/kum': 'Kumyk',
- '/languages/tsi': 'Tsimshian',
- '/languages/zap': 'Zapotec',
- '/languages/ach': 'Acoli',
- '/languages/ada': 'Adangme',
- '/languages/aka': 'Akan',
- '/languages/khi': 'Khoisan (Other)',
- '/languages/srd': 'Sardinian',
- '/languages/arn': 'Mapuche',
- '/languages/dyu': 'Dyula',
- '/languages/loz': 'Lozi',
- '/languages/ltz': 'Luxembourgish',
- '/languages/sag': 'Sango (Ubangi Creole)',
- '/languages/lez': 'Lezgian',
- '/languages/luo': 'Luo (Kenya and Tanzania)',
- '/languages/ssw': 'Swazi ',
- '/languages/krc': 'Karachay-Balkar',
- '/languages/nyn': 'Nyankole',
- '/languages/sal': 'Salishan languages',
- '/languages/jpr': 'Judeo-Persian',
- '/languages/pau': 'Palauan',
- '/languages/smi': 'Sami',
- '/languages/aar': 'Afar',
- '/languages/abk': 'Abkhaz',
- '/languages/gon': 'Gondi',
- '/languages/nzi': 'Nzima',
- '/languages/sam': 'Samaritan Aramaic',
- '/languages/sao': 'Samoan',
- '/languages/srr': 'Serer',
- '/languages/apa': 'Apache languages',
- '/languages/crh': 'Crimean Tatar',
- '/languages/efi': 'Efik',
- '/languages/iku': 'Inuktitut',
- '/languages/nav': 'Navajo',
- '/languages/pon': 'Ponape',
- '/languages/tmh': 'Tamashek',
- '/languages/aus': 'Australian languages',
- '/languages/oto': 'Otomian languages',
- '/languages/war': 'Waray',
- '/languages/ypk': 'Yupik languages',
- '/languages/ave': 'Avestan',
- '/languages/cus': 'Cushitic (Other)',
- '/languages/del': 'Delaware',
- '/languages/fon': 'Fon',
- '/languages/ina': 'Interlingua (International Auxiliary Language Association)',
- '/languages/myv': 'Erzya',
- '/languages/pag': 'Pangasinan',
- '/languages/peo': 'Old Persian (ca. 600-400 B.C.)',
- '/languages/vls': 'Flemish',
- '/languages/bai': 'Bamileke languages',
- '/languages/bla': 'Siksika',
- '/languages/day': 'Dayak',
- '/languages/men': 'Mende',
- '/languages/tai': 'Tai',
- '/languages/ton': 'Tongan',
- '/languages/uga': 'Ugaritic',
- '/languages/yao': 'Yao (Africa)',
- '/languages/zza': 'Zaza',
- '/languages/bin': 'Edo',
- '/languages/frs': 'East Frisian',
- '/languages/inh': 'Ingush',
- '/languages/mah': 'Marshallese',
- '/languages/sem': 'Semitic (Other)',
- '/languages/art': 'Artificial (Other)',
- '/languages/chy': 'Cheyenne',
- '/languages/cmc': 'Chamic languages',
- '/languages/dar': 'Dargwa',
- '/languages/dua': 'Duala',
- '/languages/elx': 'Elamite',
- '/languages/fan': 'Fang',
- '/languages/fij': 'Fijian',
- '/languages/gil': 'Gilbertese',
- '/languages/ijo': 'Ijo',
- '/languages/kam': 'Kamba',
- '/languages/nog': 'Nogai',
- '/languages/non': 'Old Norse',
- '/languages/tem': 'Temne',
- '/languages/arg': 'Aragonese',
- '/languages/arp': 'Arapaho',
- '/languages/arw': 'Arawak',
- '/languages/din': 'Dinka',
- '/languages/grb': 'Grebo',
- '/languages/kos': 'Kusaie',
- '/languages/lub': 'Luba-Katanga',
- '/languages/mnc': 'Manchu',
- '/languages/nyo': 'Nyoro',
- '/languages/rar': 'Rarotongan',
- '/languages/sel': 'Selkup',
- '/languages/tkl': 'Tokelauan',
- '/languages/tog': 'Tonga (Nyasa)',
- '/languages/tum': 'Tumbuka',
- '/languages/alt': 'Altai',
- '/languages/ase': 'American Sign Language',
- '/languages/ast': 'Asturian',
- '/languages/chk': 'Chuukese',
- '/languages/cos': 'Corsican',
- '/languages/ewo': 'Ewondo',
- '/languages/gor': 'Gorontalo',
- '/languages/hmo': 'Hiri Motu',
- '/languages/lol': 'Mongo-Nkundu',
- '/languages/lun': 'Lunda',
- '/languages/mas': 'Masai',
- '/languages/niu': 'Niuean',
- '/languages/rup': 'Aromanian',
- '/languages/sas': 'Sasak',
- '/languages/sio': 'Siouan (Other)',
- '/languages/sus': 'Susu',
- '/languages/zun': 'Zuni',
- '/languages/bat': 'Baltic (Other)',
- '/languages/car': 'Carib',
- '/languages/cha': 'Chamorro',
- '/languages/kab': 'Kabyle',
- '/languages/kau': 'Kanuri',
- '/languages/kho': 'Khotanese',
- '/languages/lua': 'Luba-Lulua',
- '/languages/mdf': 'Moksha',
- '/languages/nbl': 'Ndebele (South Africa)',
- '/languages/umb': 'Umbundu',
- '/languages/wak': 'Wakashan languages',
- '/languages/wal': 'Wolayta',
- '/languages/ale': 'Aleut',
- '/languages/bis': 'Bislama',
- '/languages/gba': 'Gbaya',
- '/languages/glv': 'Manx',
- '/languages/gul': 'Gullah',
- '/languages/ipk': 'Inupiaq',
- '/languages/krl': 'Karelian',
- '/languages/lam': 'Lamba (Zambia and Congo)',
- '/languages/sad': 'Sandawe',
- '/languages/sid': 'Sidamo',
- '/languages/snk': 'Soninke',
- '/languages/srn': 'Sranan',
- '/languages/suk': 'Sukuma',
- '/languages/ter': 'Terena',
- '/languages/tiv': 'Tiv',
- '/languages/tli': 'Tlingit',
- '/languages/tpi': 'Tok Pisin',
- '/languages/tvl': 'Tuvaluan',
- '/languages/yap': 'Yapese',
- '/languages/eka': 'Ekajuk',
- '/languages/hsb': 'Upper Sorbian',
- '/languages/ido': 'Ido',
- '/languages/kmb': 'Kimbundu',
- '/languages/kpe': 'Kpelle',
- '/languages/mwl': 'Mirandese',
- '/languages/nno': 'Nynorsk',
- '/languages/nub': 'Nubian languages',
- '/languages/osa': 'Osage',
- '/languages/sme': 'Northern Sami',
- '/languages/znd': 'Zande languages',
+ "/languages/eng": "English",
+ "/languages/fre": "French",
+ "/languages/spa": "Spanish",
+ "/languages/ger": "German",
+ "/languages/rus": "Russian",
+ "/languages/ita": "Italian",
+ "/languages/chi": "Chinese",
+ "/languages/jpn": "Japanese",
+ "/languages/por": "Portuguese",
+ "/languages/ara": "Arabic",
+ "/languages/pol": "Polish",
+ "/languages/heb": "Hebrew",
+ "/languages/kor": "Korean",
+ "/languages/dut": "Dutch",
+ "/languages/ind": "Indonesian",
+ "/languages/lat": "Latin",
+ "/languages/und": "Undetermined",
+ "/languages/cmn": "Mandarin",
+ "/languages/hin": "Hindi",
+ "/languages/swe": "Swedish",
+ "/languages/dan": "Danish",
+ "/languages/urd": "Urdu",
+ "/languages/hun": "Hungarian",
+ "/languages/cze": "Czech",
+ "/languages/tur": "Turkish",
+ "/languages/ukr": "Ukrainian",
+ "/languages/gre": "Greek",
+ "/languages/vie": "Vietnamese",
+ "/languages/bul": "Bulgarian",
+ "/languages/ben": "Bengali",
+ "/languages/rum": "Romanian",
+ "/languages/cat": "Catalan",
+ "/languages/nor": "Norwegian",
+ "/languages/tha": "Thai",
+ "/languages/per": "Persian",
+ "/languages/scr": "Croatian",
+ "/languages/mul": "Multiple languages",
+ "/languages/fin": "Finnish",
+ "/languages/tam": "Tamil",
+ "/languages/guj": "Gujarati",
+ "/languages/mar": "Marathi",
+ "/languages/scc": "Serbian",
+ "/languages/pan": "Panjabi",
+ "/languages/wel": "Welsh",
+ "/languages/tel": "Telugu",
+ "/languages/yid": "Yiddish",
+ "/languages/kan": "Kannada",
+ "/languages/slo": "Slovak",
+ "/languages/san": "Sanskrit",
+ "/languages/arm": "Armenian",
+ "/languages/mal": "Malayalam",
+ "/languages/may": "Malay",
+ "/languages/bur": "Burmese",
+ "/languages/slv": "Slovenian",
+ "/languages/lit": "Lithuanian",
+ "/languages/tib": "Tibetan",
+ "/languages/lav": "Latvian",
+ "/languages/est": "Estonian",
+ "/languages/nep": "Nepali",
+ "/languages/ori": "Oriya",
+ "/languages/mon": "Mongolian",
+ "/languages/alb": "Albanian",
+ "/languages/iri": "Irish",
+ "/languages/geo": "Georgian",
+ "/languages/afr": "Afrikaans",
+ "/languages/grc": "Ancient Greek",
+ "/languages/mac": "Macedonian",
+ "/languages/bel": "Belarusian",
+ "/languages/ice": "Icelandic",
+ "/languages/srp": "Serbian",
+ "/languages/snh": "Sinhalese",
+ "/languages/snd": "Sindhi",
+ "/languages/ota": "Turkish, Ottoman",
+ "/languages/kur": "Kurdish",
+ "/languages/aze": "Azerbaijani",
+ "/languages/pus": "Pushto",
+ "/languages/amh": "Amharic",
+ "/languages/gag": "Galician",
+ "/languages/hrv": "Croatian",
+ "/languages/sin": "Sinhalese",
+ "/languages/asm": "Assamese",
+ "/languages/uzb": "Uzbek",
+ "/languages/gae": "Scottish Gaelix",
+ "/languages/kaz": "Kazakh",
+ "/languages/swa": "Swahili",
+ "/languages/bos": "Bosnian",
+ "/languages/glg": "Galician ",
+ "/languages/baq": "Basque",
+ "/languages/tgl": "Tagalog",
+ "/languages/raj": "Rajasthani",
+ "/languages/gle": "Irish",
+ "/languages/lao": "Lao",
+ "/languages/jav": "Javanese",
+ "/languages/mai": "Maithili",
+ "/languages/tgk": "Tajik ",
+ "/languages/khm": "Khmer",
+ "/languages/roh": "Raeto-Romance",
+ "/languages/kok": "Konkani ",
+ "/languages/sit": "Sino-Tibetan (Other)",
+ "/languages/mol": "Moldavian",
+ "/languages/kir": "Kyrgyz",
+ "/languages/new": "Newari",
+ "/languages/inc": "Indic (Other)",
+ "/languages/frm": "French, Middle (ca. 1300-1600)",
+ "/languages/esp": "Esperanto",
+ "/languages/hau": "Hausa",
+ "/languages/tag": "Tagalog",
+ "/languages/tuk": "Turkmen",
+ "/languages/enm": "English, Middle (1100-1500)",
+ "/languages/map": "Austronesian (Other)",
+ "/languages/pli": "Pali",
+ "/languages/fro": "French, Old (ca. 842-1300)",
+ "/languages/nic": "Niger-Kordofanian (Other)",
+ "/languages/tir": "Tigrinya",
+ "/languages/wen": "Sorbian (Other)",
+ "/languages/bho": "Bhojpuri",
+ "/languages/roa": "Romance (Other)",
+ "/languages/tut": "Altaic (Other)",
+ "/languages/bra": "Braj",
+ "/languages/sun": "Sundanese",
+ "/languages/fiu": "Finno-Ugrian (Other)",
+ "/languages/far": "Faroese",
+ "/languages/ban": "Balinese",
+ "/languages/tar": "Tatar",
+ "/languages/bak": "Bashkir",
+ "/languages/tat": "Tatar",
+ "/languages/chu": "Church Slavic",
+ "/languages/dra": "Dravidian (Other)",
+ "/languages/pra": "Prakrit languages",
+ "/languages/paa": "Papuan (Other)",
+ "/languages/doi": "Dogri",
+ "/languages/lah": "LahndÄ",
+ "/languages/mni": "Manipuri",
+ "/languages/yor": "Yoruba",
+ "/languages/gmh": "German, Middle High (ca. 1050-1500)",
+ "/languages/kas": "Kashmiri",
+ "/languages/fri": "Frisian",
+ "/languages/mla": "Malagasy",
+ "/languages/egy": "Egyptian",
+ "/languages/rom": "Romani",
+ "/languages/syr": "Syriac, Modern",
+ "/languages/cau": "Caucasian (Other)",
+ "/languages/hbs": "Serbo-Croatian",
+ "/languages/sai": "South American Indian (Other)",
+ "/languages/pro": "Provençal (to 1500)",
+ "/languages/cpf": "Creoles and Pidgins, French-based (Other)",
+ "/languages/ang": "English, Old (ca. 450-1100)",
+ "/languages/bal": "Baluchi",
+ "/languages/gla": "Scottish Gaelic",
+ "/languages/chv": "Chuvash",
+ "/languages/kin": "Kinyarwanda",
+ "/languages/zul": "Zulu",
+ "/languages/sla": "Slavic (Other)",
+ "/languages/som": "Somali",
+ "/languages/mlt": "Maltese",
+ "/languages/uig": "Uighur",
+ "/languages/mlg": "Malagasy",
+ "/languages/sho": "Shona",
+ "/languages/lan": "Occitan (post 1500)",
+ "/languages/bre": "Breton",
+ "/languages/sco": "Scots",
+ "/languages/sso": "Sotho",
+ "/languages/myn": "Mayan languages",
+ "/languages/xho": "Xhosa",
+ "/languages/gem": "Germanic (Other)",
+ "/languages/esk": "Eskimo languages",
+ "/languages/akk": "Akkadian",
+ "/languages/div": "Maldivian",
+ "/languages/sah": "Yakut",
+ "/languages/tsw": "Tswana",
+ "/languages/nso": "Northern Sotho",
+ "/languages/pap": "Papiamento",
+ "/languages/bnt": "Bantu (Other)",
+ "/languages/oss": "Ossetic",
+ "/languages/cre": "Cree",
+ "/languages/ibo": "Igbo",
+ "/languages/fao": "Faroese",
+ "/languages/nai": "North American Indian (Other)",
+ "/languages/mag": "Magahi",
+ "/languages/arc": "Aramaic",
+ "/languages/epo": "Esperanto",
+ "/languages/kha": "Khasi",
+ "/languages/oji": "Ojibwa",
+ "/languages/que": "Quechua",
+ "/languages/lug": "Ganda",
+ "/languages/mwr": "Marwari",
+ "/languages/awa": "Awadhi ",
+ "/languages/cor": "Cornish",
+ "/languages/lad": "Ladino",
+ "/languages/dzo": "Dzongkha",
+ "/languages/cop": "Coptic",
+ "/languages/nah": "Nahuatl",
+ "/languages/cai": "Central American Indian (Other)",
+ "/languages/phi": "Philippine (Other)",
+ "/languages/moh": "Mohawk",
+ "/languages/crp": "Creoles and Pidgins (Other)",
+ "/languages/nya": "Nyanja",
+ "/languages/wol": "Wolof ",
+ "/languages/haw": "Hawaiian",
+ "/languages/eth": "Ethiopic",
+ "/languages/mis": "Miscellaneous languages",
+ "/languages/mkh": "Mon-Khmer (Other)",
+ "/languages/alg": "Algonquian (Other)",
+ "/languages/nde": "Ndebele (Zimbabwe)",
+ "/languages/ssa": "Nilo-Saharan (Other)",
+ "/languages/chm": "Mari",
+ "/languages/che": "Chechen",
+ "/languages/gez": "Ethiopic",
+ "/languages/ven": "Venda",
+ "/languages/cam": "Khmer",
+ "/languages/fur": "Friulian",
+ "/languages/ful": "Fula",
+ "/languages/gal": "Oromo",
+ "/languages/jrb": "Judeo-Arabic",
+ "/languages/bua": "Buriat",
+ "/languages/ady": "Adygei",
+ "/languages/bem": "Bemba",
+ "/languages/kar": "Karen languages",
+ "/languages/sna": "Shona",
+ "/languages/twi": "Twi",
+ "/languages/btk": "Batak",
+ "/languages/kaa": "Kara-Kalpak",
+ "/languages/kom": "Komi",
+ "/languages/sot": "Sotho",
+ "/languages/tso": "Tsonga",
+ "/languages/cpe": "Creoles and Pidgins, English-based (Other)",
+ "/languages/gua": "Guarani",
+ "/languages/mao": "Maori",
+ "/languages/mic": "Micmac",
+ "/languages/swz": "Swazi",
+ "/languages/taj": "Tajik",
+ "/languages/smo": "Samoan",
+ "/languages/ace": "Achinese",
+ "/languages/afa": "Afroasiatic (Other)",
+ "/languages/lap": "Sami",
+ "/languages/min": "Minangkabau",
+ "/languages/oci": "Occitan (post 1500)",
+ "/languages/tsn": "Tswana",
+ "/languages/pal": "Pahlavi",
+ "/languages/sux": "Sumerian",
+ "/languages/ewe": "Ewe",
+ "/languages/him": "Himachali",
+ "/languages/kaw": "Kawi",
+ "/languages/lus": "Lushai",
+ "/languages/ceb": "Cebuano",
+ "/languages/chr": "Cherokee",
+ "/languages/fil": "Filipino",
+ "/languages/ndo": "Ndonga",
+ "/languages/ilo": "Iloko",
+ "/languages/kbd": "Kabardian",
+ "/languages/orm": "Oromo",
+ "/languages/dum": "Dutch, Middle (ca. 1050-1350)",
+ "/languages/bam": "Bambara",
+ "/languages/goh": "Old High German",
+ "/languages/got": "Gothic",
+ "/languages/kon": "Kongo",
+ "/languages/mun": "Munda (Other)",
+ "/languages/kru": "Kurukh",
+ "/languages/pam": "Pampanga",
+ "/languages/grn": "Guarani",
+ "/languages/gaa": "Gã",
+ "/languages/fry": "Frisian",
+ "/languages/iba": "Iban",
+ "/languages/mak": "Makasar",
+ "/languages/kik": "Kikuyu",
+ "/languages/cho": "Choctaw",
+ "/languages/cpp": "Creoles and Pidgins, Portuguese-based (Other)",
+ "/languages/dak": "Dakota",
+ "/languages/udm": "Udmurt ",
+ "/languages/hat": "Haitian French Creole",
+ "/languages/mus": "Creek",
+ "/languages/ber": "Berber (Other)",
+ "/languages/hil": "Hiligaynon",
+ "/languages/iro": "Iroquoian (Other)",
+ "/languages/kua": "Kuanyama",
+ "/languages/mno": "Manobo languages",
+ "/languages/run": "Rundi",
+ "/languages/sat": "Santali",
+ "/languages/shn": "Shan",
+ "/languages/tyv": "Tuvinian",
+ "/languages/chg": "Chagatai",
+ "/languages/syc": "Syriac",
+ "/languages/ath": "Athapascan (Other)",
+ "/languages/aym": "Aymara",
+ "/languages/bug": "Bugis",
+ "/languages/cel": "Celtic (Other)",
+ "/languages/int": "Interlingua (International Auxiliary Language Association)",
+ "/languages/xal": "Oirat",
+ "/languages/ava": "Avaric",
+ "/languages/son": "Songhai",
+ "/languages/tah": "Tahitian",
+ "/languages/tet": "Tetum",
+ "/languages/ira": "Iranian (Other)",
+ "/languages/kac": "Kachin",
+ "/languages/nob": "Norwegian (Bokmål)",
+ "/languages/vai": "Vai",
+ "/languages/bik": "Bikol",
+ "/languages/mos": "Mooré",
+ "/languages/tig": "Tigré",
+ "/languages/fat": "Fanti",
+ "/languages/her": "Herero",
+ "/languages/kal": "Kalâtdlisut",
+ "/languages/mad": "Madurese",
+ "/languages/yue": "Cantonese",
+ "/languages/chn": "Chinook jargon",
+ "/languages/hmn": "Hmong",
+ "/languages/lin": "Lingala",
+ "/languages/man": "Mandingo",
+ "/languages/nds": "Low German",
+ "/languages/bas": "Basa",
+ "/languages/gay": "Gayo",
+ "/languages/gsw": "gsw",
+ "/languages/ine": "Indo-European (Other)",
+ "/languages/kro": "Kru (Other)",
+ "/languages/kum": "Kumyk",
+ "/languages/tsi": "Tsimshian",
+ "/languages/zap": "Zapotec",
+ "/languages/ach": "Acoli",
+ "/languages/ada": "Adangme",
+ "/languages/aka": "Akan",
+ "/languages/khi": "Khoisan (Other)",
+ "/languages/srd": "Sardinian",
+ "/languages/arn": "Mapuche",
+ "/languages/dyu": "Dyula",
+ "/languages/loz": "Lozi",
+ "/languages/ltz": "Luxembourgish",
+ "/languages/sag": "Sango (Ubangi Creole)",
+ "/languages/lez": "Lezgian",
+ "/languages/luo": "Luo (Kenya and Tanzania)",
+ "/languages/ssw": "Swazi ",
+ "/languages/krc": "Karachay-Balkar",
+ "/languages/nyn": "Nyankole",
+ "/languages/sal": "Salishan languages",
+ "/languages/jpr": "Judeo-Persian",
+ "/languages/pau": "Palauan",
+ "/languages/smi": "Sami",
+ "/languages/aar": "Afar",
+ "/languages/abk": "Abkhaz",
+ "/languages/gon": "Gondi",
+ "/languages/nzi": "Nzima",
+ "/languages/sam": "Samaritan Aramaic",
+ "/languages/sao": "Samoan",
+ "/languages/srr": "Serer",
+ "/languages/apa": "Apache languages",
+ "/languages/crh": "Crimean Tatar",
+ "/languages/efi": "Efik",
+ "/languages/iku": "Inuktitut",
+ "/languages/nav": "Navajo",
+ "/languages/pon": "Ponape",
+ "/languages/tmh": "Tamashek",
+ "/languages/aus": "Australian languages",
+ "/languages/oto": "Otomian languages",
+ "/languages/war": "Waray",
+ "/languages/ypk": "Yupik languages",
+ "/languages/ave": "Avestan",
+ "/languages/cus": "Cushitic (Other)",
+ "/languages/del": "Delaware",
+ "/languages/fon": "Fon",
+ "/languages/ina": "Interlingua (International Auxiliary Language Association)",
+ "/languages/myv": "Erzya",
+ "/languages/pag": "Pangasinan",
+ "/languages/peo": "Old Persian (ca. 600-400 B.C.)",
+ "/languages/vls": "Flemish",
+ "/languages/bai": "Bamileke languages",
+ "/languages/bla": "Siksika",
+ "/languages/day": "Dayak",
+ "/languages/men": "Mende",
+ "/languages/tai": "Tai",
+ "/languages/ton": "Tongan",
+ "/languages/uga": "Ugaritic",
+ "/languages/yao": "Yao (Africa)",
+ "/languages/zza": "Zaza",
+ "/languages/bin": "Edo",
+ "/languages/frs": "East Frisian",
+ "/languages/inh": "Ingush",
+ "/languages/mah": "Marshallese",
+ "/languages/sem": "Semitic (Other)",
+ "/languages/art": "Artificial (Other)",
+ "/languages/chy": "Cheyenne",
+ "/languages/cmc": "Chamic languages",
+ "/languages/dar": "Dargwa",
+ "/languages/dua": "Duala",
+ "/languages/elx": "Elamite",
+ "/languages/fan": "Fang",
+ "/languages/fij": "Fijian",
+ "/languages/gil": "Gilbertese",
+ "/languages/ijo": "Ijo",
+ "/languages/kam": "Kamba",
+ "/languages/nog": "Nogai",
+ "/languages/non": "Old Norse",
+ "/languages/tem": "Temne",
+ "/languages/arg": "Aragonese",
+ "/languages/arp": "Arapaho",
+ "/languages/arw": "Arawak",
+ "/languages/din": "Dinka",
+ "/languages/grb": "Grebo",
+ "/languages/kos": "Kusaie",
+ "/languages/lub": "Luba-Katanga",
+ "/languages/mnc": "Manchu",
+ "/languages/nyo": "Nyoro",
+ "/languages/rar": "Rarotongan",
+ "/languages/sel": "Selkup",
+ "/languages/tkl": "Tokelauan",
+ "/languages/tog": "Tonga (Nyasa)",
+ "/languages/tum": "Tumbuka",
+ "/languages/alt": "Altai",
+ "/languages/ase": "American Sign Language",
+ "/languages/ast": "Asturian",
+ "/languages/chk": "Chuukese",
+ "/languages/cos": "Corsican",
+ "/languages/ewo": "Ewondo",
+ "/languages/gor": "Gorontalo",
+ "/languages/hmo": "Hiri Motu",
+ "/languages/lol": "Mongo-Nkundu",
+ "/languages/lun": "Lunda",
+ "/languages/mas": "Masai",
+ "/languages/niu": "Niuean",
+ "/languages/rup": "Aromanian",
+ "/languages/sas": "Sasak",
+ "/languages/sio": "Siouan (Other)",
+ "/languages/sus": "Susu",
+ "/languages/zun": "Zuni",
+ "/languages/bat": "Baltic (Other)",
+ "/languages/car": "Carib",
+ "/languages/cha": "Chamorro",
+ "/languages/kab": "Kabyle",
+ "/languages/kau": "Kanuri",
+ "/languages/kho": "Khotanese",
+ "/languages/lua": "Luba-Lulua",
+ "/languages/mdf": "Moksha",
+ "/languages/nbl": "Ndebele (South Africa)",
+ "/languages/umb": "Umbundu",
+ "/languages/wak": "Wakashan languages",
+ "/languages/wal": "Wolayta",
+ "/languages/ale": "Aleut",
+ "/languages/bis": "Bislama",
+ "/languages/gba": "Gbaya",
+ "/languages/glv": "Manx",
+ "/languages/gul": "Gullah",
+ "/languages/ipk": "Inupiaq",
+ "/languages/krl": "Karelian",
+ "/languages/lam": "Lamba (Zambia and Congo)",
+ "/languages/sad": "Sandawe",
+ "/languages/sid": "Sidamo",
+ "/languages/snk": "Soninke",
+ "/languages/srn": "Sranan",
+ "/languages/suk": "Sukuma",
+ "/languages/ter": "Terena",
+ "/languages/tiv": "Tiv",
+ "/languages/tli": "Tlingit",
+ "/languages/tpi": "Tok Pisin",
+ "/languages/tvl": "Tuvaluan",
+ "/languages/yap": "Yapese",
+ "/languages/eka": "Ekajuk",
+ "/languages/hsb": "Upper Sorbian",
+ "/languages/ido": "Ido",
+ "/languages/kmb": "Kimbundu",
+ "/languages/kpe": "Kpelle",
+ "/languages/mwl": "Mirandese",
+ "/languages/nno": "Nynorsk",
+ "/languages/nub": "Nubian languages",
+ "/languages/osa": "Osage",
+ "/languages/sme": "Northern Sami",
+ "/languages/znd": "Zande languages",
}
diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py
index f57fbc1c..a8f85834 100644
--- a/bookwyrm/connectors/self_connector.py
+++ b/bookwyrm/connectors/self_connector.py
@@ -1,26 +1,28 @@
-''' using a bookwyrm instance as a source of book data '''
+""" using a bookwyrm instance as a source of book data """
from functools import reduce
import operator
from django.contrib.postgres.search import SearchRank, SearchVector
-from django.db.models import Count, F, Q
+from django.db.models import Count, OuterRef, Subquery, F, Q
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector):
- ''' instantiate a connector '''
+ """instantiate a connector"""
+
# pylint: disable=arguments-differ
- def search(self, query, min_confidence=0.1, raw=False):
- ''' search your local database '''
+ def search(self, query, min_confidence=0.1, raw=False, filters=None):
+ """search your local database"""
+ filters = filters or []
if not query:
return []
# first, try searching unqiue identifiers
- results = search_identifiers(query)
+ results = search_identifiers(query, *filters)
if not results:
# then try searching title/author
- results = search_title_author(query, min_confidence)
+ results = search_title_author(query, min_confidence, *filters)
search_results = []
for result in results:
if raw:
@@ -33,19 +35,58 @@ class Connector(AbstractConnector):
search_results.sort(key=lambda r: r.confidence, reverse=True)
return search_results
+ def isbn_search(self, query, raw=False):
+ """search your local database"""
+ if not query:
+ return []
+
+ filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
+ results = models.Edition.objects.filter(
+ reduce(operator.or_, (Q(**f) for f in filters))
+ ).distinct()
+
+ # when there are multiple editions of the same work, pick the default.
+ # it would be odd for this to happen.
+
+ default_editions = models.Edition.objects.filter(
+ parent_work=OuterRef("parent_work")
+ ).order_by("-edition_rank")
+ results = (
+ results.annotate(
+ default_id=Subquery(default_editions.values("id")[:1])
+ ).filter(default_id=F("id"))
+ or results
+ )
+
+ search_results = []
+ for result in results:
+ if raw:
+ search_results.append(result)
+ else:
+ search_results.append(self.format_search_result(result))
+ if len(search_results) >= 10:
+ break
+ return search_results
def format_search_result(self, search_result):
+ cover = None
+ if search_result.cover:
+ cover = "%s%s" % (self.covers_url, search_result.cover)
+
return SearchResult(
title=search_result.title,
key=search_result.remote_id,
author=search_result.author_text,
- year=search_result.published_date.year if \
- search_result.published_date else None,
+ year=search_result.published_date.year
+ if search_result.published_date
+ else None,
connector=self,
- confidence=search_result.rank if \
- hasattr(search_result, 'rank') else 1,
+ cover=cover,
+ confidence=search_result.rank if hasattr(search_result, "rank") else 1,
)
+ def format_isbn_search_result(self, search_result):
+ return self.format_search_result(search_result)
def is_work_data(self, data):
pass
@@ -59,56 +100,71 @@ class Connector(AbstractConnector):
def get_authors_from_data(self, data):
return None
+ def parse_isbn_search_data(self, data):
+ """it's already in the right format, don't even worry about it"""
+ return data
+
def parse_search_data(self, data):
- ''' it's already in the right format, don't even worry about it '''
+ """it's already in the right format, don't even worry about it"""
return data
def expand_book_data(self, book):
pass
-def search_identifiers(query):
- ''' tries remote_id, isbn; defined as dedupe fields on the model '''
- filters = [{f.name: query} for f in models.Edition._meta.get_fields() \
- if hasattr(f, 'deduplication_field') and f.deduplication_field]
+def search_identifiers(query, *filters):
+ """tries remote_id, isbn; defined as dedupe fields on the model"""
+ or_filters = [
+ {f.name: query}
+ for f in models.Edition._meta.get_fields()
+ if hasattr(f, "deduplication_field") and f.deduplication_field
+ ]
results = models.Edition.objects.filter(
- reduce(operator.or_, (Q(**f) for f in filters))
+ *filters, reduce(operator.or_, (Q(**f) for f in or_filters))
).distinct()
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
- return results.filter(parent_work__default_edition__id=F('id')) \
- or results
+ default_editions = models.Edition.objects.filter(
+ parent_work=OuterRef("parent_work")
+ ).order_by("-edition_rank")
+ return (
+ results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
+ default_id=F("id")
+ )
+ or results
+ )
-def search_title_author(query, min_confidence):
- ''' searches for title and author '''
- vector = SearchVector('title', weight='A') +\
- SearchVector('subtitle', weight='B') +\
- SearchVector('authors__name', weight='C') +\
- SearchVector('series', weight='D')
+def search_title_author(query, min_confidence, *filters):
+ """searches for title and author"""
+ vector = (
+ SearchVector("title", weight="A")
+ + SearchVector("subtitle", weight="B")
+ + SearchVector("authors__name", weight="C")
+ + SearchVector("series", weight="D")
+ )
- results = models.Edition.objects.annotate(
- search=vector
- ).annotate(
- rank=SearchRank(vector, query)
- ).filter(
- rank__gt=min_confidence
- ).order_by('-rank')
+ results = (
+ models.Edition.objects.annotate(search=vector)
+ .annotate(rank=SearchRank(vector, query))
+ .filter(*filters, rank__gt=min_confidence)
+ .order_by("-rank")
+ )
# when there are multiple editions of the same work, pick the closest
- editions_of_work = results.values(
- 'parent_work'
- ).annotate(
- Count('parent_work')
- ).values_list('parent_work')
+ editions_of_work = (
+ results.values("parent_work")
+ .annotate(Count("parent_work"))
+ .values_list("parent_work")
+ )
for work_id in set(editions_of_work):
editions = results.filter(parent_work=work_id)
- default = editions.filter(parent_work__default_edition=F('id'))
- default_rank = default.first().rank if default.exists() else 0
+ default = editions.order_by("-edition_rank").first()
+ default_rank = default.rank if default else 0
# if mutliple books have the top rank, pick the default edition
if default_rank == editions.first().rank:
- yield default.first()
+ yield default
else:
yield editions.first()
diff --git a/bookwyrm/connectors/settings.py b/bookwyrm/connectors/settings.py
index e04aedef..4cc98da7 100644
--- a/bookwyrm/connectors/settings.py
+++ b/bookwyrm/connectors/settings.py
@@ -1,3 +1,3 @@
-''' settings book data connectors '''
+""" settings book data connectors """
-CONNECTORS = ['openlibrary', 'self_connector', 'bookwyrm_connector']
+CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"]
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index a1471ac4..b77c62b0 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -1,8 +1,10 @@
-''' customize the info available in context for rendering templates '''
+""" customize the info available in context for rendering templates """
from bookwyrm import models
-def site_settings(request):# pylint: disable=unused-argument
- ''' include the custom info about the site '''
+
+def site_settings(request): # pylint: disable=unused-argument
+ """include the custom info about the site"""
return {
- 'site': models.SiteSettings.objects.get()
+ "site": models.SiteSettings.objects.get(),
+ "active_announcements": models.Announcement.active_announcements(),
}
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index 2319d467..657310b0 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -1,25 +1,66 @@
-''' send emails '''
-from django.core.mail import send_mail
+""" send emails """
+from django.core.mail import EmailMultiAlternatives
+from django.template.loader import get_template
-from bookwyrm import models
+from bookwyrm import models, settings
from bookwyrm.tasks import app
+from bookwyrm.settings import DOMAIN
+
+
+def email_data():
+ """fields every email needs"""
+ site = models.SiteSettings.objects.get()
+ if site.logo_small:
+ logo_path = "/images/{}".format(site.logo_small.url)
+ else:
+ logo_path = "/static/images/logo-small.png"
+
+ return {
+ "site_name": site.name,
+ "logo": logo_path,
+ "domain": DOMAIN,
+ "user": None,
+ }
+
+
+def invite_email(invite_request):
+ """send out an invite code"""
+ data = email_data()
+ data["invite_link"] = invite_request.invite.link
+ send_email.delay(invite_request.email, *format_email("invite", data))
+
def password_reset_email(reset_code):
- ''' generate a password reset email '''
- site = models.SiteSettings.get()
- send_email.delay(
- reset_code.user.email,
- 'Reset your password on %s' % site.name,
- 'Your password reset link: %s' % reset_code.link
+ """generate a password reset email"""
+ data = email_data()
+ data["reset_link"] = reset_code.link
+ data["user"] = reset_code.user.display_name
+ send_email.delay(reset_code.user.email, *format_email("password_reset", data))
+
+
+def format_email(email_name, data):
+ """render the email templates"""
+ subject = (
+ get_template("email/{}/subject.html".format(email_name)).render(data).strip()
)
+ html_content = (
+ get_template("email/{}/html_content.html".format(email_name))
+ .render(data)
+ .strip()
+ )
+ text_content = (
+ get_template("email/{}/text_content.html".format(email_name))
+ .render(data)
+ .strip()
+ )
+ return (subject, html_content, text_content)
+
@app.task
-def send_email(recipient, subject, message):
- ''' use a task to send the email '''
- send_mail(
- subject,
- message,
- None, # sender will be the config default
- [recipient],
- fail_silently=False
+def send_email(recipient, subject, html_content, text_content):
+ """use a task to send the email"""
+ email = EmailMultiAlternatives(
+ subject, text_content, settings.DEFAULT_FROM_EMAIL, [recipient]
)
+ email.attach_alternative(html_content, "text/html")
+ email.send()
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 19f692db..004dbddb 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -1,125 +1,172 @@
-''' using django model forms '''
+""" using django model forms """
import datetime
from collections import defaultdict
from django import forms
-from django.forms import ModelForm, PasswordInput, widgets
+from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
from django.forms.widgets import Textarea
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
from bookwyrm import models
class CustomForm(ModelForm):
- ''' add css classes to the forms '''
+ """add css classes to the forms"""
+
def __init__(self, *args, **kwargs):
- css_classes = defaultdict(lambda: '')
- css_classes['text'] = 'input'
- css_classes['password'] = 'input'
- css_classes['email'] = 'input'
- css_classes['number'] = 'input'
- css_classes['checkbox'] = 'checkbox'
- css_classes['textarea'] = 'textarea'
+ css_classes = defaultdict(lambda: "")
+ css_classes["text"] = "input"
+ css_classes["password"] = "input"
+ css_classes["email"] = "input"
+ css_classes["number"] = "input"
+ css_classes["checkbox"] = "checkbox"
+ css_classes["textarea"] = "textarea"
super(CustomForm, self).__init__(*args, **kwargs)
for visible in self.visible_fields():
- if hasattr(visible.field.widget, 'input_type'):
+ if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea):
- input_type = 'textarea'
- visible.field.widget.attrs['cols'] = None
- visible.field.widget.attrs['rows'] = None
- visible.field.widget.attrs['class'] = css_classes[input_type]
+ input_type = "textarea"
+ visible.field.widget.attrs["cols"] = None
+ visible.field.widget.attrs["rows"] = None
+ visible.field.widget.attrs["class"] = css_classes[input_type]
# pylint: disable=missing-class-docstring
class LoginForm(CustomForm):
class Meta:
model = models.User
- fields = ['localname', 'password']
+ fields = ["localname", "password"]
help_texts = {f: None for f in fields}
widgets = {
- 'password': PasswordInput(),
+ "password": PasswordInput(),
}
class RegisterForm(CustomForm):
class Meta:
model = models.User
- fields = ['localname', 'email', 'password']
+ fields = ["localname", "email", "password"]
help_texts = {f: None for f in fields}
- widgets = {
- 'password': PasswordInput()
- }
+ widgets = {"password": PasswordInput()}
class RatingForm(CustomForm):
class Meta:
- model = models.Review
- fields = ['user', 'book', 'content', 'rating', 'privacy']
+ model = models.ReviewRating
+ fields = ["user", "book", "rating", "privacy"]
class ReviewForm(CustomForm):
class Meta:
model = models.Review
fields = [
- 'user', 'book',
- 'name', 'content', 'rating',
- 'content_warning', 'sensitive',
- 'privacy']
+ "user",
+ "book",
+ "name",
+ "content",
+ "rating",
+ "content_warning",
+ "sensitive",
+ "privacy",
+ ]
class CommentForm(CustomForm):
class Meta:
model = models.Comment
fields = [
- 'user', 'book', 'content',
- 'content_warning', 'sensitive',
- 'privacy']
+ "user",
+ "book",
+ "content",
+ "content_warning",
+ "sensitive",
+ "privacy",
+ "progress",
+ "progress_mode",
+ ]
class QuotationForm(CustomForm):
class Meta:
model = models.Quotation
fields = [
- 'user', 'book', 'quote', 'content',
- 'content_warning', 'sensitive', 'privacy']
+ "user",
+ "book",
+ "quote",
+ "content",
+ "content_warning",
+ "sensitive",
+ "privacy",
+ ]
class ReplyForm(CustomForm):
class Meta:
model = models.Status
fields = [
- 'user', 'content', 'content_warning', 'sensitive',
- 'reply_parent', 'privacy']
+ "user",
+ "content",
+ "content_warning",
+ "sensitive",
+ "reply_parent",
+ "privacy",
+ ]
+
class StatusForm(CustomForm):
class Meta:
model = models.Status
- fields = [
- 'user', 'content', 'content_warning', 'sensitive', 'privacy']
+ fields = ["user", "content", "content_warning", "sensitive", "privacy"]
class EditUserForm(CustomForm):
class Meta:
model = models.User
fields = [
- 'avatar', 'name', 'email', 'summary', 'manually_approves_followers', 'default_post_privacy'
+ "avatar",
+ "name",
+ "email",
+ "summary",
+ "show_goal",
+ "manually_approves_followers",
+ "default_post_privacy",
+ "discoverable",
+ "preferred_timezone",
]
help_texts = {f: None for f in fields}
-class TagForm(CustomForm):
+class LimitedEditUserForm(CustomForm):
class Meta:
- model = models.Tag
- fields = ['name']
+ model = models.User
+ fields = [
+ "avatar",
+ "name",
+ "summary",
+ "manually_approves_followers",
+ "discoverable",
+ ]
help_texts = {f: None for f in fields}
- labels = {'name': 'Add a tag'}
+
+
+class DeleteUserForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = ["password"]
+
+
+class UserGroupForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = ["groups"]
class CoverForm(CustomForm):
class Meta:
model = models.Book
- fields = ['cover']
+ fields = ["cover"]
help_texts = {f: None for f in fields}
@@ -127,79 +174,100 @@ class EditionForm(CustomForm):
class Meta:
model = models.Edition
exclude = [
- 'remote_id',
- 'origin_id',
- 'created_date',
- 'updated_date',
- 'edition_rank',
-
- 'authors',# TODO
- 'parent_work',
- 'shelves',
-
- 'subjects',# TODO
- 'subject_places',# TODO
-
- 'connector',
+ "remote_id",
+ "origin_id",
+ "created_date",
+ "updated_date",
+ "edition_rank",
+ "authors",
+ "parent_work",
+ "shelves",
+ "subjects", # TODO
+ "subject_places", # TODO
+ "connector",
]
+
class AuthorForm(CustomForm):
class Meta:
model = models.Author
exclude = [
- 'remote_id',
- 'origin_id',
- 'created_date',
- 'updated_date',
+ "remote_id",
+ "origin_id",
+ "created_date",
+ "updated_date",
]
class ImportForm(forms.Form):
csv_file = forms.FileField()
+
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
- ''' human-readable exiration time buckets '''
+ """human-readable exiration time buckets"""
selected_string = super().value_from_datadict(data, files, name)
- if selected_string == 'day':
+ if selected_string == "day":
interval = datetime.timedelta(days=1)
- elif selected_string == 'week':
+ elif selected_string == "week":
interval = datetime.timedelta(days=7)
- elif selected_string == 'month':
- interval = datetime.timedelta(days=31) # Close enough?
- elif selected_string == 'forever':
+ elif selected_string == "month":
+ interval = datetime.timedelta(days=31) # Close enough?
+ elif selected_string == "forever":
return None
else:
- return selected_string # "This will raise
+ return selected_string # "This will raise
return timezone.now() + interval
+
+class InviteRequestForm(CustomForm):
+ def clean(self):
+ """make sure the email isn't in use by a registered user"""
+ cleaned_data = super().clean()
+ email = cleaned_data.get("email")
+ if email and models.User.objects.filter(email=email).exists():
+ self.add_error("email", _("A user with this email already exists."))
+
+ class Meta:
+ model = models.InviteRequest
+ fields = ["email"]
+
+
class CreateInviteForm(CustomForm):
class Meta:
model = models.SiteInvite
- exclude = ['code', 'user', 'times_used']
+ exclude = ["code", "user", "times_used", "invitees"]
widgets = {
- 'expiry': ExpiryWidget(choices=[
- ('day', 'One Day'),
- ('week', 'One Week'),
- ('month', 'One Month'),
- ('forever', 'Does Not Expire')]),
- 'use_limit': widgets.Select(
- choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]]
- + [(None, 'Unlimited')])
+ "expiry": ExpiryWidget(
+ choices=[
+ ("day", _("One Day")),
+ ("week", _("One Week")),
+ ("month", _("One Month")),
+ ("forever", _("Does Not Expire")),
+ ]
+ ),
+ "use_limit": widgets.Select(
+ choices=[
+ (i, _("%(count)d uses" % {"count": i}))
+ for i in [1, 5, 10, 25, 50, 100]
+ ]
+ + [(None, _("Unlimited"))]
+ ),
}
+
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
- fields = ['user', 'name', 'privacy']
+ fields = ["user", "name", "privacy"]
class GoalForm(CustomForm):
class Meta:
model = models.AnnualGoal
- fields = ['user', 'year', 'goal', 'privacy']
+ fields = ["user", "year", "goal", "privacy"]
class SiteForm(CustomForm):
@@ -208,7 +276,42 @@ class SiteForm(CustomForm):
exclude = []
+class AnnouncementForm(CustomForm):
+ class Meta:
+ model = models.Announcement
+ exclude = ["remote_id"]
+
+
class ListForm(CustomForm):
class Meta:
model = models.List
- fields = ['user', 'name', 'description', 'curation', 'privacy']
+ fields = ["user", "name", "description", "curation", "privacy"]
+
+
+class ReportForm(CustomForm):
+ class Meta:
+ model = models.Report
+ fields = ["user", "reporter", "statuses", "note"]
+
+
+class ServerForm(CustomForm):
+ class Meta:
+ model = models.FederatedServer
+ exclude = ["remote_id"]
+
+
+class SortListForm(forms.Form):
+ sort_by = ChoiceField(
+ choices=(
+ ("order", _("List Order")),
+ ("title", _("Book Title")),
+ ("rating", _("Rating")),
+ ),
+ label=_("Sort By"),
+ )
+ direction = ChoiceField(
+ choices=(
+ ("ascending", _("Ascending")),
+ ("descending", _("Descending")),
+ ),
+ )
diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py
deleted file mode 100644
index 1b2b971c..00000000
--- a/bookwyrm/goodreads_import.py
+++ /dev/null
@@ -1,121 +0,0 @@
-''' handle reading a csv from goodreads '''
-import csv
-import logging
-
-from bookwyrm import models
-from bookwyrm.models import ImportJob, ImportItem
-from bookwyrm.tasks import app
-
-logger = logging.getLogger(__name__)
-
-
-def create_job(user, csv_file, include_reviews, privacy):
- ''' check over a csv and creates a database entry for the job'''
- job = ImportJob.objects.create(
- user=user,
- include_reviews=include_reviews,
- privacy=privacy
- )
- for index, entry in enumerate(list(csv.DictReader(csv_file))):
- if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
- raise ValueError('Author, title, and isbn must be in data.')
- ImportItem(job=job, index=index, data=entry).save()
- return job
-
-
-def create_retry_job(user, original_job, items):
- ''' retry items that didn't import '''
- job = ImportJob.objects.create(
- user=user,
- include_reviews=original_job.include_reviews,
- privacy=original_job.privacy,
- retry=True
- )
- for item in items:
- ImportItem(job=job, index=item.index, data=item.data).save()
- return job
-
-
-def start_import(job):
- ''' initalizes a csv import job '''
- result = import_data.delay(job.id)
- job.task_id = result.id
- job.save()
-
-
-@app.task
-def import_data(job_id):
- ''' does the actual lookup work in a celery task '''
- job = ImportJob.objects.get(id=job_id)
- try:
- for item in job.items.all():
- try:
- item.resolve()
- except Exception as e:# pylint: disable=broad-except
- logger.exception(e)
- item.fail_reason = 'Error loading book'
- item.save()
- continue
-
- if item.book:
- item.save()
-
- # shelves book and handles reviews
- handle_imported_book(
- job.user, item, job.include_reviews, job.privacy)
- else:
- item.fail_reason = 'Could not find a match for book'
- item.save()
- finally:
- job.complete = True
- job.save()
-
-
-def handle_imported_book(user, item, include_reviews, privacy):
- ''' process a goodreads csv and then post about it '''
- if isinstance(item.book, models.Work):
- item.book = item.book.default_edition
- if not item.book:
- return
-
- existing_shelf = models.ShelfBook.objects.filter(
- book=item.book, user=user).exists()
-
- # shelve the book if it hasn't been shelved already
- if item.shelf and not existing_shelf:
- desired_shelf = models.Shelf.objects.get(
- identifier=item.shelf,
- user=user
- )
- models.ShelfBook.objects.create(
- book=item.book, shelf=desired_shelf, user=user)
-
- for read in item.reads:
- # check for an existing readthrough with the same dates
- if models.ReadThrough.objects.filter(
- user=user, book=item.book,
- start_date=read.start_date,
- finish_date=read.finish_date
- ).exists():
- continue
- read.book = item.book
- read.user = user
- read.save()
-
- if include_reviews and (item.rating or item.review):
- review_title = 'Review of {!r} on Goodreads'.format(
- item.book.title,
- ) if item.review else ''
-
- # we don't know the publication date of the review,
- # but "now" is a bad guess
- published_date_guess = item.date_read or item.date_added
- models.Review.objects.create(
- user=user,
- book=item.book,
- name=review_title,
- content=item.review,
- rating=item.rating,
- published_date=published_date_guess,
- privacy=privacy,
- )
diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py
new file mode 100644
index 00000000..d4890070
--- /dev/null
+++ b/bookwyrm/importers/__init__.py
@@ -0,0 +1,6 @@
+""" import classes """
+
+from .importer import Importer
+from .goodreads_import import GoodreadsImporter
+from .librarything_import import LibrarythingImporter
+from .storygraph_import import StorygraphImporter
diff --git a/bookwyrm/importers/goodreads_import.py b/bookwyrm/importers/goodreads_import.py
new file mode 100644
index 00000000..7b577ea8
--- /dev/null
+++ b/bookwyrm/importers/goodreads_import.py
@@ -0,0 +1,16 @@
+""" handle reading a csv from goodreads """
+from . import Importer
+
+
+class GoodreadsImporter(Importer):
+ """GoodReads is the default importer, thus Importer follows its structure.
+ For a more complete example of overriding see librarything_import.py"""
+
+ service = "GoodReads"
+
+ def parse_fields(self, entry):
+ """handle the specific fields in goodreads csvs"""
+ entry.update({"import_source": self.service})
+ # add missing 'Date Started' field
+ entry.update({"Date Started": None})
+ return entry
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
new file mode 100644
index 00000000..89c62e73
--- /dev/null
+++ b/bookwyrm/importers/importer.py
@@ -0,0 +1,148 @@
+""" handle reading a csv from an external service, defaults are from GoodReads """
+import csv
+import logging
+
+from bookwyrm import models
+from bookwyrm.models import ImportJob, ImportItem
+from bookwyrm.tasks import app
+
+logger = logging.getLogger(__name__)
+
+
+class Importer:
+ """Generic class for csv data import from an outside service"""
+
+ service = "Unknown"
+ delimiter = ","
+ encoding = "UTF-8"
+ mandatory_fields = ["Title", "Author"]
+
+ def create_job(self, user, csv_file, include_reviews, privacy):
+ """check over a csv and creates a database entry for the job"""
+ job = ImportJob.objects.create(
+ user=user, include_reviews=include_reviews, privacy=privacy
+ )
+ for index, entry in enumerate(
+ list(csv.DictReader(csv_file, delimiter=self.delimiter))
+ ):
+ if not all(x in entry for x in self.mandatory_fields):
+ raise ValueError("Author and title must be in data.")
+ entry = self.parse_fields(entry)
+ self.save_item(job, index, entry)
+ return job
+
+ def save_item(self, job, index, data): # pylint: disable=no-self-use
+ """creates and saves an import item"""
+ ImportItem(job=job, index=index, data=data).save()
+
+ def parse_fields(self, entry):
+ """updates csv data with additional info"""
+ entry.update({"import_source": self.service})
+ return entry
+
+ def create_retry_job(self, user, original_job, items):
+ """retry items that didn't import"""
+ job = ImportJob.objects.create(
+ user=user,
+ include_reviews=original_job.include_reviews,
+ privacy=original_job.privacy,
+ retry=True,
+ )
+ for item in items:
+ self.save_item(job, item.index, item.data)
+ return job
+
+ def start_import(self, job):
+ """initalizes a csv import job"""
+ result = import_data.delay(self.service, job.id)
+ job.task_id = result.id
+ job.save()
+
+
+@app.task
+def import_data(source, job_id):
+ """does the actual lookup work in a celery task"""
+ job = ImportJob.objects.get(id=job_id)
+ try:
+ for item in job.items.all():
+ try:
+ item.resolve()
+ except Exception as e: # pylint: disable=broad-except
+ logger.exception(e)
+ item.fail_reason = "Error loading book"
+ item.save()
+ continue
+
+ if item.book:
+ item.save()
+
+ # shelves book and handles reviews
+ handle_imported_book(
+ source, job.user, item, job.include_reviews, job.privacy
+ )
+ else:
+ item.fail_reason = "Could not find a match for book"
+ item.save()
+ finally:
+ job.complete = True
+ job.save()
+
+
+def handle_imported_book(source, user, item, include_reviews, privacy):
+ """process a csv and then post about it"""
+ if isinstance(item.book, models.Work):
+ item.book = item.book.default_edition
+ if not item.book:
+ return
+
+ existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
+
+ # shelve the book if it hasn't been shelved already
+ if item.shelf and not existing_shelf:
+ desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
+ models.ShelfBook.objects.create(book=item.book, shelf=desired_shelf, user=user)
+
+ for read in item.reads:
+ # check for an existing readthrough with the same dates
+ if models.ReadThrough.objects.filter(
+ user=user,
+ book=item.book,
+ start_date=read.start_date,
+ finish_date=read.finish_date,
+ ).exists():
+ continue
+ read.book = item.book
+ read.user = user
+ read.save()
+
+ if include_reviews and (item.rating or item.review):
+ # we don't know the publication date of the review,
+ # but "now" is a bad guess
+ published_date_guess = item.date_read or item.date_added
+ if item.review:
+ review_title = (
+ "Review of {!r} on {!r}".format(
+ item.book.title,
+ source,
+ )
+ if item.review
+ else ""
+ )
+ models.Review.objects.create(
+ user=user,
+ book=item.book,
+ name=review_title,
+ content=item.review,
+ rating=item.rating,
+ published_date=published_date_guess,
+ privacy=privacy,
+ )
+ else:
+ # just a rating
+ models.ReviewRating.objects.create(
+ user=user,
+ book=item.book,
+ rating=item.rating,
+ published_date=published_date_guess,
+ privacy=privacy,
+ )
diff --git a/bookwyrm/importers/librarything_import.py b/bookwyrm/importers/librarything_import.py
new file mode 100644
index 00000000..b3175a82
--- /dev/null
+++ b/bookwyrm/importers/librarything_import.py
@@ -0,0 +1,42 @@
+""" handle reading a csv from librarything """
+import re
+import math
+
+from . import Importer
+
+
+class LibrarythingImporter(Importer):
+ """csv downloads from librarything"""
+
+ service = "LibraryThing"
+ delimiter = "\t"
+ encoding = "ISO-8859-1"
+ # mandatory_fields : fields matching the book title and author
+ mandatory_fields = ["Title", "Primary Author"]
+
+ def parse_fields(self, entry):
+ """custom parsing for librarything"""
+ data = {}
+ data["import_source"] = self.service
+ data["Book Id"] = entry["Book Id"]
+ data["Title"] = entry["Title"]
+ data["Author"] = entry["Primary Author"]
+ data["ISBN13"] = entry["ISBN"]
+ data["My Review"] = entry["Review"]
+ if entry["Rating"]:
+ data["My Rating"] = math.ceil(float(entry["Rating"]))
+ else:
+ data["My Rating"] = ""
+ data["Date Added"] = re.sub(r"\[|\]", "", entry["Entry Date"])
+ data["Date Started"] = re.sub(r"\[|\]", "", entry["Date Started"])
+ data["Date Read"] = re.sub(r"\[|\]", "", entry["Date Read"])
+
+ data["Exclusive Shelf"] = None
+ if data["Date Read"]:
+ data["Exclusive Shelf"] = "read"
+ elif data["Date Started"]:
+ data["Exclusive Shelf"] = "reading"
+ else:
+ data["Exclusive Shelf"] = "to-read"
+
+ return data
diff --git a/bookwyrm/importers/storygraph_import.py b/bookwyrm/importers/storygraph_import.py
new file mode 100644
index 00000000..25498432
--- /dev/null
+++ b/bookwyrm/importers/storygraph_import.py
@@ -0,0 +1,34 @@
+""" handle reading a csv from librarything """
+import re
+import math
+
+from . import Importer
+
+
+class StorygraphImporter(Importer):
+ """csv downloads from librarything"""
+
+ service = "Storygraph"
+ # mandatory_fields : fields matching the book title and author
+ mandatory_fields = ["Title"]
+
+ def parse_fields(self, entry):
+ """custom parsing for storygraph"""
+ data = {}
+ data["import_source"] = self.service
+ data["Title"] = entry["Title"]
+ data["Author"] = entry["Authors"] if "Authors" in entry else entry["Author"]
+ data["ISBN13"] = entry["ISBN"]
+ data["My Review"] = entry["Review"]
+ if entry["Star Rating"]:
+ data["My Rating"] = math.ceil(float(entry["Star Rating"]))
+ else:
+ data["My Rating"] = ""
+
+ data["Date Added"] = re.sub(r"[/]", "-", entry["Date Added"])
+ data["Date Read"] = re.sub(r"[/]", "-", entry["Last Date Read"])
+
+ data["Exclusive Shelf"] = (
+ {"read": "read", "currently-reading": "reading", "to-read": "to-read"}
+ ).get(entry["Read Status"], None)
+ return data
diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py
deleted file mode 100644
index 18db1069..00000000
--- a/bookwyrm/incoming.py
+++ /dev/null
@@ -1,360 +0,0 @@
-''' handles all of the activity coming in to the server '''
-import json
-from urllib.parse import urldefrag
-
-import django.db.utils
-from django.http import HttpResponse
-from django.http import HttpResponseBadRequest, HttpResponseNotFound
-from django.views.decorators.csrf import csrf_exempt
-from django.views.decorators.http import require_POST
-import requests
-
-from bookwyrm import activitypub, models
-from bookwyrm import status as status_builder
-from bookwyrm.tasks import app
-from bookwyrm.signatures import Signature
-
-
-@csrf_exempt
-@require_POST
-def inbox(request, username):
- ''' incoming activitypub events '''
- try:
- models.User.objects.get(localname=username)
- except models.User.DoesNotExist:
- return HttpResponseNotFound()
-
- return shared_inbox(request)
-
-
-@csrf_exempt
-@require_POST
-def shared_inbox(request):
- ''' incoming activitypub events '''
- try:
- resp = request.body
- activity = json.loads(resp)
- if isinstance(activity, str):
- activity = json.loads(activity)
- activity_object = activity['object']
- except (json.decoder.JSONDecodeError, KeyError):
- return HttpResponseBadRequest()
-
- if not has_valid_signature(request, activity):
- if activity['type'] == 'Delete':
- # Pretend that unauth'd deletes succeed. Auth may be failing because
- # the resource or owner of the resource might have been deleted.
- return HttpResponse()
- return HttpResponse(status=401)
-
- # if this isn't a file ripe for refactor, I don't know what is.
- handlers = {
- 'Follow': handle_follow,
- 'Accept': handle_follow_accept,
- 'Reject': handle_follow_reject,
- 'Block': handle_block,
- 'Create': {
- 'BookList': handle_create_list,
- 'Note': handle_create_status,
- 'Article': handle_create_status,
- 'Review': handle_create_status,
- 'Comment': handle_create_status,
- 'Quotation': handle_create_status,
- },
- 'Delete': handle_delete_status,
- 'Like': handle_favorite,
- 'Announce': handle_boost,
- 'Add': {
- 'Edition': handle_add,
- },
- 'Undo': {
- 'Follow': handle_unfollow,
- 'Like': handle_unfavorite,
- 'Announce': handle_unboost,
- 'Block': handle_unblock,
- },
- 'Update': {
- 'Person': handle_update_user,
- 'Edition': handle_update_edition,
- 'Work': handle_update_work,
- 'BookList': handle_update_list,
- },
- }
- activity_type = activity['type']
-
- handler = handlers.get(activity_type, None)
- if isinstance(handler, dict):
- handler = handler.get(activity_object['type'], None)
-
- if not handler:
- return HttpResponseNotFound()
-
- handler.delay(activity)
- return HttpResponse()
-
-
-def has_valid_signature(request, activity):
- ''' verify incoming signature '''
- try:
- signature = Signature.parse(request)
-
- key_actor = urldefrag(signature.key_id).url
- if key_actor != activity.get('actor'):
- raise ValueError("Wrong actor created signature.")
-
- remote_user = activitypub.resolve_remote_id(models.User, key_actor)
- if not remote_user:
- return False
-
- try:
- signature.verify(remote_user.key_pair.public_key, request)
- except ValueError:
- old_key = remote_user.key_pair.public_key
- remote_user = activitypub.resolve_remote_id(
- models.User, remote_user.remote_id, refresh=True
- )
- if remote_user.key_pair.public_key == old_key:
- raise # Key unchanged.
- signature.verify(remote_user.key_pair.public_key, request)
- except (ValueError, requests.exceptions.HTTPError):
- return False
- return True
-
-
-@app.task
-def handle_follow(activity):
- ''' someone wants to follow a local user '''
- try:
- relationship = activitypub.Follow(
- **activity
- ).to_model(models.UserFollowRequest)
- except django.db.utils.IntegrityError as err:
- if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
- raise
- relationship = models.UserFollowRequest.objects.get(
- remote_id=activity['id']
- )
- # send the accept normally for a duplicate request
-
- if not relationship.user_object.manually_approves_followers:
- relationship.accept()
-
-
-@app.task
-def handle_unfollow(activity):
- ''' unfollow a local user '''
- obj = activity['object']
- requester = activitypub.resolve_remote_id(models.User, obj['actor'])
- to_unfollow = models.User.objects.get(remote_id=obj['object'])
- # raises models.User.DoesNotExist
-
- to_unfollow.followers.remove(requester)
-
-
-@app.task
-def handle_follow_accept(activity):
- ''' hurray, someone remote accepted a follow request '''
- # figure out who they want to follow
- requester = models.User.objects.get(remote_id=activity['object']['actor'])
- # figure out who they are
- accepter = activitypub.resolve_remote_id(models.User, activity['actor'])
-
- try:
- request = models.UserFollowRequest.objects.get(
- user_subject=requester,
- user_object=accepter
- )
- request.delete()
- except models.UserFollowRequest.DoesNotExist:
- pass
- accepter.followers.add(requester)
-
-
-@app.task
-def handle_follow_reject(activity):
- ''' someone is rejecting a follow request '''
- requester = models.User.objects.get(remote_id=activity['object']['actor'])
- rejecter = activitypub.resolve_remote_id(models.User, activity['actor'])
-
- request = models.UserFollowRequest.objects.get(
- user_subject=requester,
- user_object=rejecter
- )
- request.delete()
- #raises models.UserFollowRequest.DoesNotExist
-
-@app.task
-def handle_block(activity):
- ''' blocking a user '''
- # create "block" databse entry
- activitypub.Block(**activity).to_model(models.UserBlocks)
- # the removing relationships is handled in post-save hook in model
-
-
-@app.task
-def handle_unblock(activity):
- ''' undoing a block '''
- try:
- block_id = activity['object']['id']
- except KeyError:
- return
- try:
- block = models.UserBlocks.objects.get(remote_id=block_id)
- except models.UserBlocks.DoesNotExist:
- return
- block.delete()
-
-
-@app.task
-def handle_create_list(activity):
- ''' a new list '''
- activity = activity['object']
- activitypub.BookList(**activity).to_model(models.List)
-
-
-@app.task
-def handle_update_list(activity):
- ''' update a list '''
- try:
- book_list = models.List.objects.get(remote_id=activity['object']['id'])
- except models.List.DoesNotExist:
- book_list = None
- activitypub.BookList(
- **activity['object']).to_model(models.List, instance=book_list)
-
-
-@app.task
-def handle_create_status(activity):
- ''' someone did something, good on them '''
- # deduplicate incoming activities
- activity = activity['object']
- status_id = activity.get('id')
- if models.Status.objects.filter(remote_id=status_id).count():
- return
-
- try:
- serializer = activitypub.activity_objects[activity['type']]
- except KeyError:
- return
-
- activity = serializer(**activity)
- try:
- model = models.activity_models[activity.type]
- except KeyError:
- # not a type of status we are prepared to deserialize
- return
-
- status = activity.to_model(model)
- if not status:
- # it was discarded because it's not a bookwyrm type
- return
-
-
-@app.task
-def handle_delete_status(activity):
- ''' remove a status '''
- try:
- status_id = activity['object']['id']
- except TypeError:
- # this isn't a great fix, because you hit this when mastadon
- # is trying to delete a user.
- return
- try:
- status = models.Status.objects.get(
- remote_id=status_id
- )
- except models.Status.DoesNotExist:
- return
- models.Notification.objects.filter(related_status=status).all().delete()
- status_builder.delete_status(status)
-
-
-@app.task
-def handle_favorite(activity):
- ''' approval of your good good post '''
- fav = activitypub.Like(**activity)
- # we dont know this status, we don't care about this status
- if not models.Status.objects.filter(remote_id=fav.object).exists():
- return
-
- fav = fav.to_model(models.Favorite)
- if fav.user.local:
- return
-
-
-@app.task
-def handle_unfavorite(activity):
- ''' approval of your good good post '''
- like = models.Favorite.objects.filter(
- remote_id=activity['object']['id']
- ).first()
- if not like:
- return
- like.delete()
-
-
-@app.task
-def handle_boost(activity):
- ''' someone gave us a boost! '''
- try:
- activitypub.Boost(**activity).to_model(models.Boost)
- except activitypub.ActivitySerializerError:
- # this probably just means we tried to boost an unknown status
- return
-
-
-@app.task
-def handle_unboost(activity):
- ''' someone gave us a boost! '''
- boost = models.Boost.objects.filter(
- remote_id=activity['object']['id']
- ).first()
- if boost:
- boost.delete()
-
-
-@app.task
-def handle_add(activity):
- ''' putting a book on a shelf '''
- #this is janky as heck but I haven't thought of a better solution
- try:
- activitypub.AddBook(**activity).to_model(models.ShelfBook)
- return
- except activitypub.ActivitySerializerError:
- pass
- try:
- activitypub.AddListItem(**activity).to_model(models.ListItem)
- return
- except activitypub.ActivitySerializerError:
- pass
- try:
- activitypub.AddBook(**activity).to_model(models.UserTag)
- return
- except activitypub.ActivitySerializerError:
- pass
-
-
-@app.task
-def handle_update_user(activity):
- ''' receive an updated user Person activity object '''
- try:
- user = models.User.objects.get(remote_id=activity['object']['id'])
- except models.User.DoesNotExist:
- # who is this person? who cares
- return
- activitypub.Person(
- **activity['object']
- ).to_model(models.User, instance=user)
- # model save() happens in the to_model function
-
-
-@app.task
-def handle_update_edition(activity):
- ''' a remote instance changed a book (Document) '''
- activitypub.Edition(**activity['object']).to_model(models.Edition)
-
-
-@app.task
-def handle_update_work(activity):
- ''' a remote instance changed a book (Document) '''
- activitypub.Work(**activity['object']).to_model(models.Work)
diff --git a/bookwyrm/management/commands/deduplicate_book_data.py b/bookwyrm/management/commands/deduplicate_book_data.py
index 044b2a98..ed01a784 100644
--- a/bookwyrm/management/commands/deduplicate_book_data.py
+++ b/bookwyrm/management/commands/deduplicate_book_data.py
@@ -1,26 +1,20 @@
-''' PROCEED WITH CAUTION: uses deduplication fields to permanently
-merge book data objects '''
+""" PROCEED WITH CAUTION: uses deduplication fields to permanently
+merge book data objects """
from django.core.management.base import BaseCommand
from django.db.models import Count
from bookwyrm import models
def update_related(canonical, obj):
- ''' update all the models with fk to the object being removed '''
+ """update all the models with fk to the object being removed"""
# move related models to canonical
related_models = [
- (r.remote_field.name, r.related_model) for r in \
- canonical._meta.related_objects]
+ (r.remote_field.name, r.related_model) for r in canonical._meta.related_objects
+ ]
for (related_field, related_model) in related_models:
- related_objs = related_model.objects.filter(
- **{related_field: obj})
+ related_objs = related_model.objects.filter(**{related_field: obj})
for related_obj in related_objs:
- print(
- 'replacing in',
- related_model.__name__,
- related_field,
- related_obj.id
- )
+ print("replacing in", related_model.__name__, related_field, related_obj.id)
try:
setattr(related_obj, related_field, canonical)
related_obj.save()
@@ -30,40 +24,41 @@ def update_related(canonical, obj):
def copy_data(canonical, obj):
- ''' try to get the most data possible '''
+ """try to get the most data possible"""
for data_field in obj._meta.get_fields():
- if not hasattr(data_field, 'activitypub_field'):
+ if not hasattr(data_field, "activitypub_field"):
continue
data_value = getattr(obj, data_field.name)
if not data_value:
continue
if not getattr(canonical, data_field.name):
- print('setting data field', data_field.name, data_value)
+ print("setting data field", data_field.name, data_value)
setattr(canonical, data_field.name, data_value)
canonical.save()
def dedupe_model(model):
- ''' combine duplicate editions and update related models '''
+ """combine duplicate editions and update related models"""
fields = model._meta.get_fields()
- dedupe_fields = [f for f in fields if \
- hasattr(f, 'deduplication_field') and f.deduplication_field]
+ dedupe_fields = [
+ f for f in fields if hasattr(f, "deduplication_field") and f.deduplication_field
+ ]
for field in dedupe_fields:
- dupes = model.objects.values(field.name).annotate(
- Count(field.name)
- ).filter(**{'%s__count__gt' % field.name: 1})
+ dupes = (
+ model.objects.values(field.name)
+ .annotate(Count(field.name))
+ .filter(**{"%s__count__gt" % field.name: 1})
+ )
for dupe in dupes:
value = dupe[field.name]
- if not value or value == '':
+ if not value or value == "":
continue
- print('----------')
+ print("----------")
print(dupe)
- objs = model.objects.filter(
- **{field.name: value}
- ).order_by('id')
+ objs = model.objects.filter(**{field.name: value}).order_by("id")
canonical = objs.first()
- print('keeping', canonical.remote_id)
+ print("keeping", canonical.remote_id)
for obj in objs[1:]:
print(obj.remote_id)
copy_data(canonical, obj)
@@ -73,11 +68,12 @@ def dedupe_model(model):
class Command(BaseCommand):
- ''' dedplucate allllll the book data models '''
- help = 'merges duplicate book data'
+ """dedplucate allllll the book data models"""
+
+ help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
- ''' run deudplications '''
+ """run deudplications"""
dedupe_model(models.Edition)
dedupe_model(models.Work)
dedupe_model(models.Author)
diff --git a/bookwyrm/management/commands/erase_streams.py b/bookwyrm/management/commands/erase_streams.py
new file mode 100644
index 00000000..1d34b1bb
--- /dev/null
+++ b/bookwyrm/management/commands/erase_streams.py
@@ -0,0 +1,24 @@
+""" Delete user streams """
+from django.core.management.base import BaseCommand
+import redis
+
+from bookwyrm import settings
+
+r = redis.Redis(
+ host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
+)
+
+
+def erase_streams():
+ """throw the whole redis away"""
+ r.flushall()
+
+
+class Command(BaseCommand):
+ """delete activity streams for all users"""
+
+ help = "Delete all the user streams"
+ # pylint: disable=no-self-use,unused-argument
+ def handle(self, *args, **options):
+ """flush all, baby"""
+ erase_streams()
diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py
index 9fd11787..71ac511a 100644
--- a/bookwyrm/management/commands/initdb.py
+++ b/bookwyrm/management/commands/initdb.py
@@ -1,55 +1,70 @@
-from django.core.management.base import BaseCommand, CommandError
+""" What you need in the database to make it work """
+from django.core.management.base import BaseCommand
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
-from bookwyrm.models import Connector, SiteSettings, User
+from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
from bookwyrm.settings import DOMAIN
+
def init_groups():
- groups = ['admin', 'moderator', 'editor']
+ """permission levels"""
+ groups = ["admin", "moderator", "editor"]
for group in groups:
Group.objects.create(name=group)
+
def init_permissions():
- permissions = [{
- 'codename': 'edit_instance_settings',
- 'name': 'change the instance info',
- 'groups': ['admin',]
- }, {
- 'codename': 'set_user_group',
- 'name': 'change what group a user is in',
- 'groups': ['admin', 'moderator']
- }, {
- 'codename': 'control_federation',
- 'name': 'control who to federate with',
- 'groups': ['admin', 'moderator']
- }, {
- 'codename': 'create_invites',
- 'name': 'issue invitations to join',
- 'groups': ['admin', 'moderator']
- }, {
- 'codename': 'moderate_user',
- 'name': 'deactivate or silence a user',
- 'groups': ['admin', 'moderator']
- }, {
- 'codename': 'moderate_post',
- 'name': 'delete other users\' posts',
- 'groups': ['admin', 'moderator']
- }, {
- 'codename': 'edit_book',
- 'name': 'edit book info',
- 'groups': ['admin', 'moderator', 'editor']
- }]
+ """permission types"""
+ permissions = [
+ {
+ "codename": "edit_instance_settings",
+ "name": "change the instance info",
+ "groups": [
+ "admin",
+ ],
+ },
+ {
+ "codename": "set_user_group",
+ "name": "change what group a user is in",
+ "groups": ["admin", "moderator"],
+ },
+ {
+ "codename": "control_federation",
+ "name": "control who to federate with",
+ "groups": ["admin", "moderator"],
+ },
+ {
+ "codename": "create_invites",
+ "name": "issue invitations to join",
+ "groups": ["admin", "moderator"],
+ },
+ {
+ "codename": "moderate_user",
+ "name": "deactivate or silence a user",
+ "groups": ["admin", "moderator"],
+ },
+ {
+ "codename": "moderate_post",
+ "name": "delete other users' posts",
+ "groups": ["admin", "moderator"],
+ },
+ {
+ "codename": "edit_book",
+ "name": "edit book info",
+ "groups": ["admin", "moderator", "editor"],
+ },
+ ]
content_type = ContentType.objects.get_for_model(User)
for permission in permissions:
permission_obj = Permission.objects.create(
- codename=permission['codename'],
- name=permission['name'],
+ codename=permission["codename"],
+ name=permission["name"],
content_type=content_type,
)
# add the permission to the appropriate groups
- for group_name in permission['groups']:
+ for group_name in permission["groups"]:
Group.objects.get(name=group_name).permissions.add(permission_obj)
# while the groups and permissions shouldn't be changed because the code
@@ -57,48 +72,81 @@ def init_permissions():
def init_connectors():
+ """access book data sources"""
Connector.objects.create(
identifier=DOMAIN,
- name='Local',
+ name="Local",
local=True,
- connector_file='self_connector',
- base_url='https://%s' % DOMAIN,
- books_url='https://%s/book' % DOMAIN,
- covers_url='https://%s/images/covers' % DOMAIN,
- search_url='https://%s/search?q=' % DOMAIN,
+ connector_file="self_connector",
+ base_url="https://%s" % DOMAIN,
+ books_url="https://%s/book" % DOMAIN,
+ covers_url="https://%s/images/" % DOMAIN,
+ search_url="https://%s/search?q=" % DOMAIN,
+ isbn_search_url="https://%s/isbn/" % DOMAIN,
priority=1,
)
Connector.objects.create(
- identifier='bookwyrm.social',
- name='BookWyrm dot Social',
- connector_file='bookwyrm_connector',
- base_url='https://bookwyrm.social',
- books_url='https://bookwyrm.social/book',
- covers_url='https://bookwyrm.social/images/covers',
- search_url='https://bookwyrm.social/search?q=',
+ identifier="bookwyrm.social",
+ name="BookWyrm dot Social",
+ connector_file="bookwyrm_connector",
+ base_url="https://bookwyrm.social",
+ books_url="https://bookwyrm.social/book",
+ covers_url="https://bookwyrm.social/images/",
+ search_url="https://bookwyrm.social/search?q=",
+ isbn_search_url="https://bookwyrm.social/isbn/",
priority=2,
)
Connector.objects.create(
- identifier='openlibrary.org',
- name='OpenLibrary',
- connector_file='openlibrary',
- base_url='https://openlibrary.org',
- books_url='https://openlibrary.org',
- covers_url='https://covers.openlibrary.org',
- search_url='https://openlibrary.org/search?q=',
+ identifier="inventaire.io",
+ name="Inventaire",
+ connector_file="inventaire",
+ base_url="https://inventaire.io",
+ books_url="https://inventaire.io/api/entities",
+ covers_url="https://inventaire.io",
+ search_url="https://inventaire.io/api/search?types=works&types=works&search=",
+ isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
priority=3,
)
+ Connector.objects.create(
+ identifier="openlibrary.org",
+ name="OpenLibrary",
+ connector_file="openlibrary",
+ base_url="https://openlibrary.org",
+ books_url="https://openlibrary.org",
+ covers_url="https://covers.openlibrary.org",
+ search_url="https://openlibrary.org/search?q=",
+ isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
+ priority=3,
+ )
+
+
+def init_federated_servers():
+ """big no to nazis"""
+ built_in_blocks = ["gab.ai", "gab.com"]
+ for server in built_in_blocks:
+ FederatedServer.objects.create(
+ server_name=server,
+ status="blocked",
+ )
+
+
def init_settings():
- SiteSettings.objects.create()
+ """info about the instance"""
+ SiteSettings.objects.create(
+ support_link="https://www.patreon.com/bookwyrm",
+ support_title="Patreon",
+ )
+
class Command(BaseCommand):
- help = 'Initializes the database with starter data'
+ help = "Initializes the database with starter data"
def handle(self, *args, **options):
init_groups()
init_permissions()
init_connectors()
+ init_federated_servers()
init_settings()
diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py
new file mode 100644
index 00000000..04f6bf6e
--- /dev/null
+++ b/bookwyrm/management/commands/populate_streams.py
@@ -0,0 +1,30 @@
+""" Re-create user streams """
+from django.core.management.base import BaseCommand
+import redis
+
+from bookwyrm import activitystreams, models, settings
+
+r = redis.Redis(
+ host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
+)
+
+
+def populate_streams():
+ """build all the streams for all the users"""
+ users = models.User.objects.filter(
+ local=True,
+ is_active=True,
+ )
+ for user in users:
+ for stream in activitystreams.streams.values():
+ stream.populate_streams(user)
+
+
+class Command(BaseCommand):
+ """start all over with user streams"""
+
+ help = "Populate streams for all users"
+ # pylint: disable=no-self-use,unused-argument
+ def handle(self, *args, **options):
+ """run feed builder"""
+ populate_streams()
diff --git a/bookwyrm/management/commands/remove_editions.py b/bookwyrm/management/commands/remove_editions.py
index c5153f44..9eb9b7da 100644
--- a/bookwyrm/management/commands/remove_editions.py
+++ b/bookwyrm/management/commands/remove_editions.py
@@ -1,34 +1,42 @@
-''' PROCEED WITH CAUTION: this permanently deletes book data '''
+""" PROCEED WITH CAUTION: this permanently deletes book data """
from django.core.management.base import BaseCommand
from django.db.models import Count, Q
from bookwyrm import models
def remove_editions():
- ''' combine duplicate editions and update related models '''
+ """combine duplicate editions and update related models"""
# not in use
- filters = {'%s__isnull' % r.name: True \
- for r in models.Edition._meta.related_objects}
+ filters = {
+ "%s__isnull" % r.name: True for r in models.Edition._meta.related_objects
+ }
# no cover, no identifying fields
- filters['cover'] = ''
- null_fields = {'%s__isnull' % f: True for f in \
- ['isbn_10', 'isbn_13', 'oclc_number']}
+ filters["cover"] = ""
+ null_fields = {
+ "%s__isnull" % f: True for f in ["isbn_10", "isbn_13", "oclc_number"]
+ }
- editions = models.Edition.objects.filter(
- Q(languages=[]) | Q(languages__contains=['English']),
- **filters, **null_fields
- ).annotate(Count('parent_work__editions')).filter(
- # mustn't be the only edition for the work
- parent_work__editions__count__gt=1
+ editions = (
+ models.Edition.objects.filter(
+ Q(languages=[]) | Q(languages__contains=["English"]),
+ **filters,
+ **null_fields
+ )
+ .annotate(Count("parent_work__editions"))
+ .filter(
+ # mustn't be the only edition for the work
+ parent_work__editions__count__gt=1
+ )
)
print(editions.count())
editions.delete()
class Command(BaseCommand):
- ''' dedplucate allllll the book data models '''
- help = 'merges duplicate book data'
+ """dedplucate allllll the book data models"""
+
+ help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
- ''' run deudplications '''
+ """run deudplications"""
remove_editions()
diff --git a/bookwyrm/migrations/0001_initial.py b/bookwyrm/migrations/0001_initial.py
index 347057e1..a405b956 100644
--- a/bookwyrm/migrations/0001_initial.py
+++ b/bookwyrm/migrations/0001_initial.py
@@ -15,199 +15,448 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
- ('auth', '0011_update_proxy_permissions'),
+ ("auth", "0011_update_proxy_permissions"),
]
operations = [
migrations.CreateModel(
- name='User',
+ name="User",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('password', models.CharField(max_length=128, verbose_name='password')),
- ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
- ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
- ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
- ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
- ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
- ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
- ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
- ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
- ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
- ('private_key', models.TextField(blank=True, null=True)),
- ('public_key', models.TextField(blank=True, null=True)),
- ('actor', models.CharField(max_length=255, unique=True)),
- ('inbox', models.CharField(max_length=255, unique=True)),
- ('shared_inbox', models.CharField(blank=True, max_length=255, null=True)),
- ('outbox', models.CharField(max_length=255, unique=True)),
- ('summary', models.TextField(blank=True, null=True)),
- ('local', models.BooleanField(default=True)),
- ('fedireads_user', models.BooleanField(default=True)),
- ('localname', models.CharField(max_length=255, null=True, unique=True)),
- ('name', models.CharField(blank=True, max_length=100, null=True)),
- ('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("password", models.CharField(max_length=128, verbose_name="password")),
+ (
+ "last_login",
+ models.DateTimeField(
+ blank=True, null=True, verbose_name="last login"
+ ),
+ ),
+ (
+ "is_superuser",
+ models.BooleanField(
+ default=False,
+ help_text="Designates that this user has all permissions without explicitly assigning them.",
+ verbose_name="superuser status",
+ ),
+ ),
+ (
+ "username",
+ models.CharField(
+ error_messages={
+ "unique": "A user with that username already exists."
+ },
+ help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
+ max_length=150,
+ unique=True,
+ validators=[
+ django.contrib.auth.validators.UnicodeUsernameValidator()
+ ],
+ verbose_name="username",
+ ),
+ ),
+ (
+ "first_name",
+ models.CharField(
+ blank=True, max_length=30, verbose_name="first name"
+ ),
+ ),
+ (
+ "last_name",
+ models.CharField(
+ blank=True, max_length=150, verbose_name="last name"
+ ),
+ ),
+ (
+ "email",
+ models.EmailField(
+ blank=True, max_length=254, verbose_name="email address"
+ ),
+ ),
+ (
+ "is_staff",
+ models.BooleanField(
+ default=False,
+ help_text="Designates whether the user can log into this admin site.",
+ verbose_name="staff status",
+ ),
+ ),
+ (
+ "is_active",
+ models.BooleanField(
+ default=True,
+ help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
+ verbose_name="active",
+ ),
+ ),
+ (
+ "date_joined",
+ models.DateTimeField(
+ default=django.utils.timezone.now, verbose_name="date joined"
+ ),
+ ),
+ ("private_key", models.TextField(blank=True, null=True)),
+ ("public_key", models.TextField(blank=True, null=True)),
+ ("actor", models.CharField(max_length=255, unique=True)),
+ ("inbox", models.CharField(max_length=255, unique=True)),
+ (
+ "shared_inbox",
+ models.CharField(blank=True, max_length=255, null=True),
+ ),
+ ("outbox", models.CharField(max_length=255, unique=True)),
+ ("summary", models.TextField(blank=True, null=True)),
+ ("local", models.BooleanField(default=True)),
+ ("fedireads_user", models.BooleanField(default=True)),
+ ("localname", models.CharField(max_length=255, null=True, unique=True)),
+ ("name", models.CharField(blank=True, max_length=100, null=True)),
+ (
+ "avatar",
+ models.ImageField(blank=True, null=True, upload_to="avatars/"),
+ ),
],
options={
- 'verbose_name': 'user',
- 'verbose_name_plural': 'users',
- 'abstract': False,
+ "verbose_name": "user",
+ "verbose_name_plural": "users",
+ "abstract": False,
},
managers=[
- ('objects', django.contrib.auth.models.UserManager()),
+ ("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
- name='Author',
+ name="Author",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('openlibrary_key', models.CharField(max_length=255)),
- ('data', JSONField()),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("openlibrary_key", models.CharField(max_length=255)),
+ ("data", JSONField()),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='Book',
+ name="Book",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('openlibrary_key', models.CharField(max_length=255, unique=True)),
- ('data', JSONField()),
- ('cover', models.ImageField(blank=True, null=True, upload_to='covers/')),
- ('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
- ('authors', models.ManyToManyField(to='bookwyrm.Author')),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("openlibrary_key", models.CharField(max_length=255, unique=True)),
+ ("data", JSONField()),
+ (
+ "cover",
+ models.ImageField(blank=True, null=True, upload_to="covers/"),
+ ),
+ (
+ "added_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ("authors", models.ManyToManyField(to="bookwyrm.Author")),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='FederatedServer',
+ name="FederatedServer",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('server_name', models.CharField(max_length=255, unique=True)),
- ('status', models.CharField(default='federated', max_length=255)),
- ('application_type', models.CharField(max_length=255, null=True)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("server_name", models.CharField(max_length=255, unique=True)),
+ ("status", models.CharField(default="federated", max_length=255)),
+ ("application_type", models.CharField(max_length=255, null=True)),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='Shelf',
+ name="Shelf",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('name', models.CharField(max_length=100)),
- ('identifier', models.CharField(max_length=100)),
- ('editable', models.BooleanField(default=True)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("name", models.CharField(max_length=100)),
+ ("identifier", models.CharField(max_length=100)),
+ ("editable", models.BooleanField(default=True)),
],
),
migrations.CreateModel(
- name='Status',
+ name="Status",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('status_type', models.CharField(default='Note', max_length=255)),
- ('activity_type', models.CharField(default='Note', max_length=255)),
- ('local', models.BooleanField(default=True)),
- ('privacy', models.CharField(default='public', max_length=255)),
- ('sensitive', models.BooleanField(default=False)),
- ('mention_books', models.ManyToManyField(related_name='mention_book', to='bookwyrm.Book')),
- ('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)),
- ('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("status_type", models.CharField(default="Note", max_length=255)),
+ ("activity_type", models.CharField(default="Note", max_length=255)),
+ ("local", models.BooleanField(default=True)),
+ ("privacy", models.CharField(default="public", max_length=255)),
+ ("sensitive", models.BooleanField(default=False)),
+ (
+ "mention_books",
+ models.ManyToManyField(
+ related_name="mention_book", to="bookwyrm.Book"
+ ),
+ ),
+ (
+ "mention_users",
+ models.ManyToManyField(
+ related_name="mention_user", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ (
+ "reply_parent",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Status",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='UserRelationship',
+ name="UserRelationship",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('status', models.CharField(default='follows', max_length=100, null=True)),
- ('relationship_id', models.CharField(max_length=100)),
- ('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_object', to=settings.AUTH_USER_MODEL)),
- ('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_subject', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ (
+ "status",
+ models.CharField(default="follows", max_length=100, null=True),
+ ),
+ ("relationship_id", models.CharField(max_length=100)),
+ (
+ "user_object",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "user_subject",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='ShelfBook',
+ name="ShelfBook",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
- ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
- ('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Shelf')),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ (
+ "added_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
+ ),
+ ),
+ (
+ "shelf",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Shelf"
+ ),
+ ),
],
options={
- 'unique_together': {('book', 'shelf')},
+ "unique_together": {("book", "shelf")},
},
),
migrations.AddField(
- model_name='shelf',
- name='books',
- field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Book'),
+ model_name="shelf",
+ name="books",
+ field=models.ManyToManyField(
+ through="bookwyrm.ShelfBook", to="bookwyrm.Book"
+ ),
),
migrations.AddField(
- model_name='shelf',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="shelf",
+ name="user",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AddField(
- model_name='book',
- name='shelves',
- field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Shelf'),
+ model_name="book",
+ name="shelves",
+ field=models.ManyToManyField(
+ through="bookwyrm.ShelfBook", to="bookwyrm.Shelf"
+ ),
),
migrations.AddField(
- model_name='user',
- name='federated_server',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.FederatedServer'),
+ model_name="user",
+ name="federated_server",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.FederatedServer",
+ ),
),
migrations.AddField(
- model_name='user',
- name='followers',
- field=models.ManyToManyField(through='bookwyrm.UserRelationship', to=settings.AUTH_USER_MODEL),
+ model_name="user",
+ name="followers",
+ field=models.ManyToManyField(
+ through="bookwyrm.UserRelationship", to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AddField(
- model_name='user',
- name='groups',
- field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
+ model_name="user",
+ name="groups",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.Group",
+ verbose_name="groups",
+ ),
),
migrations.AddField(
- model_name='user',
- name='user_permissions',
- field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
+ model_name="user",
+ name="user_permissions",
+ field=models.ManyToManyField(
+ blank=True,
+ help_text="Specific permissions for this user.",
+ related_name="user_set",
+ related_query_name="user",
+ to="auth.Permission",
+ verbose_name="user permissions",
+ ),
),
migrations.AlterUniqueTogether(
- name='shelf',
- unique_together={('user', 'identifier')},
+ name="shelf",
+ unique_together={("user", "identifier")},
),
migrations.CreateModel(
- name='Review',
+ name="Review",
fields=[
- ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
- ('name', models.CharField(max_length=255)),
- ('rating', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
- ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
+ (
+ "status_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Status",
+ ),
+ ),
+ ("name", models.CharField(max_length=255)),
+ (
+ "rating",
+ models.IntegerField(
+ default=0,
+ validators=[
+ django.core.validators.MinValueValidator(0),
+ django.core.validators.MaxValueValidator(5),
+ ],
+ ),
+ ),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=('bookwyrm.status',),
+ bases=("bookwyrm.status",),
),
]
diff --git a/bookwyrm/migrations/0002_auto_20200219_0816.py b/bookwyrm/migrations/0002_auto_20200219_0816.py
index 9cb5b726..07daad93 100644
--- a/bookwyrm/migrations/0002_auto_20200219_0816.py
+++ b/bookwyrm/migrations/0002_auto_20200219_0816.py
@@ -8,31 +8,59 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0001_initial'),
+ ("bookwyrm", "0001_initial"),
]
operations = [
migrations.CreateModel(
- name='Favorite',
+ name="Favorite",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('content', models.TextField(blank=True, null=True)),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("content", models.TextField(blank=True, null=True)),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ (
+ "status",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Status",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'unique_together': {('user', 'status')},
+ "unique_together": {("user", "status")},
},
),
migrations.AddField(
- model_name='status',
- name='favorites',
- field=models.ManyToManyField(related_name='user_favorites', through='bookwyrm.Favorite', to=settings.AUTH_USER_MODEL),
+ model_name="status",
+ name="favorites",
+ field=models.ManyToManyField(
+ related_name="user_favorites",
+ through="bookwyrm.Favorite",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AddField(
- model_name='user',
- name='favorites',
- field=models.ManyToManyField(related_name='favorite_statuses', through='bookwyrm.Favorite', to='bookwyrm.Status'),
+ model_name="user",
+ name="favorites",
+ field=models.ManyToManyField(
+ related_name="favorite_statuses",
+ through="bookwyrm.Favorite",
+ to="bookwyrm.Status",
+ ),
),
]
diff --git a/bookwyrm/migrations/0003_auto_20200221_0131.py b/bookwyrm/migrations/0003_auto_20200221_0131.py
index e53f042b..e3e16414 100644
--- a/bookwyrm/migrations/0003_auto_20200221_0131.py
+++ b/bookwyrm/migrations/0003_auto_20200221_0131.py
@@ -7,87 +7,89 @@ import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0002_auto_20200219_0816'),
+ ("bookwyrm", "0002_auto_20200219_0816"),
]
operations = [
migrations.RemoveField(
- model_name='author',
- name='content',
+ model_name="author",
+ name="content",
),
migrations.RemoveField(
- model_name='book',
- name='content',
+ model_name="book",
+ name="content",
),
migrations.RemoveField(
- model_name='favorite',
- name='content',
+ model_name="favorite",
+ name="content",
),
migrations.RemoveField(
- model_name='federatedserver',
- name='content',
+ model_name="federatedserver",
+ name="content",
),
migrations.RemoveField(
- model_name='shelf',
- name='content',
+ model_name="shelf",
+ name="content",
),
migrations.RemoveField(
- model_name='shelfbook',
- name='content',
+ model_name="shelfbook",
+ name="content",
),
migrations.RemoveField(
- model_name='userrelationship',
- name='content',
+ model_name="userrelationship",
+ name="content",
),
migrations.AddField(
- model_name='author',
- name='updated_date',
+ model_name="author",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='book',
- name='updated_date',
+ model_name="book",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='favorite',
- name='updated_date',
+ model_name="favorite",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='federatedserver',
- name='updated_date',
+ model_name="federatedserver",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='shelf',
- name='updated_date',
+ model_name="shelf",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='shelfbook',
- name='updated_date',
+ model_name="shelfbook",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='status',
- name='updated_date',
+ model_name="status",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='user',
- name='created_date',
- field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+ model_name="user",
+ name="created_date",
+ field=models.DateTimeField(
+ auto_now_add=True, default=django.utils.timezone.now
+ ),
preserve_default=False,
),
migrations.AddField(
- model_name='user',
- name='updated_date',
+ model_name="user",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
- model_name='userrelationship',
- name='updated_date',
+ model_name="userrelationship",
+ name="updated_date",
field=models.DateTimeField(auto_now=True),
),
]
diff --git a/bookwyrm/migrations/0004_tag.py b/bookwyrm/migrations/0004_tag.py
index 20955000..b6210070 100644
--- a/bookwyrm/migrations/0004_tag.py
+++ b/bookwyrm/migrations/0004_tag.py
@@ -8,22 +8,41 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0003_auto_20200221_0131'),
+ ("bookwyrm", "0003_auto_20200221_0131"),
]
operations = [
migrations.CreateModel(
- name='Tag',
+ name="Tag",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('name', models.CharField(max_length=140)),
- ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("name", models.CharField(max_length=140)),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'unique_together': {('user', 'book', 'name')},
+ "unique_together": {("user", "book", "name")},
},
),
]
diff --git a/bookwyrm/migrations/0005_auto_20200221_1645.py b/bookwyrm/migrations/0005_auto_20200221_1645.py
index dbd87e92..449ce041 100644
--- a/bookwyrm/migrations/0005_auto_20200221_1645.py
+++ b/bookwyrm/migrations/0005_auto_20200221_1645.py
@@ -5,27 +5,27 @@ from django.db import migrations, models
def populate_identifiers(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- tags = app_registry.get_model('bookwyrm', 'Tag')
+ tags = app_registry.get_model("bookwyrm", "Tag")
for tag in tags.objects.using(db_alias):
- tag.identifier = re.sub(r'\W+', '-', tag.name).lower()
+ tag.identifier = re.sub(r"\W+", "-", tag.name).lower()
tag.save()
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0004_tag'),
+ ("bookwyrm", "0004_tag"),
]
operations = [
migrations.AddField(
- model_name='tag',
- name='identifier',
+ model_name="tag",
+ name="identifier",
field=models.CharField(max_length=100, null=True),
),
migrations.AlterField(
- model_name='tag',
- name='name',
+ model_name="tag",
+ name="name",
field=models.CharField(max_length=100),
),
migrations.RunPython(populate_identifiers),
diff --git a/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py b/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py
index 6a149ab5..c06fa40a 100644
--- a/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py
+++ b/bookwyrm/migrations/0006_auto_20200221_1702_squashed_0064_merge_20201101_1913.py
@@ -16,1056 +16,1647 @@ import uuid
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0005_auto_20200221_1645'),
+ ("bookwyrm", "0005_auto_20200221_1645"),
]
operations = [
migrations.AlterField(
- model_name='tag',
- name='identifier',
+ model_name="tag",
+ name="identifier",
field=models.CharField(max_length=100),
),
migrations.AddConstraint(
- model_name='userrelationship',
- constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='followers_unique'),
+ model_name="userrelationship",
+ constraint=models.UniqueConstraint(
+ fields=("user_subject", "user_object"), name="followers_unique"
+ ),
),
migrations.RemoveField(
- model_name='user',
- name='followers',
+ model_name="user",
+ name="followers",
),
migrations.AddField(
- model_name='status',
- name='published_date',
+ model_name="status",
+ name="published_date",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.CreateModel(
- name='Edition',
+ name="Edition",
fields=[
- ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')),
- ('isbn', models.CharField(max_length=255, null=True, unique=True)),
- ('oclc_number', models.CharField(max_length=255, null=True, unique=True)),
- ('pages', models.IntegerField(null=True)),
+ (
+ "book_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Book",
+ ),
+ ),
+ ("isbn", models.CharField(max_length=255, null=True, unique=True)),
+ (
+ "oclc_number",
+ models.CharField(max_length=255, null=True, unique=True),
+ ),
+ ("pages", models.IntegerField(null=True)),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=('bookwyrm.book',),
+ bases=("bookwyrm.book",),
),
migrations.CreateModel(
- name='Work',
+ name="Work",
fields=[
- ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')),
- ('lccn', models.CharField(max_length=255, null=True, unique=True)),
+ (
+ "book_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Book",
+ ),
+ ),
+ ("lccn", models.CharField(max_length=255, null=True, unique=True)),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=('bookwyrm.book',),
+ bases=("bookwyrm.book",),
),
migrations.RemoveField(
- model_name='author',
- name='data',
+ model_name="author",
+ name="data",
),
migrations.RemoveField(
- model_name='book',
- name='added_by',
+ model_name="book",
+ name="added_by",
),
migrations.RemoveField(
- model_name='book',
- name='data',
+ model_name="book",
+ name="data",
),
migrations.AddField(
- model_name='author',
- name='bio',
+ model_name="author",
+ name="bio",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
- model_name='author',
- name='born',
+ model_name="author",
+ name="born",
field=models.DateTimeField(null=True),
),
migrations.AddField(
- model_name='author',
- name='died',
+ model_name="author",
+ name="died",
field=models.DateTimeField(null=True),
),
migrations.AddField(
- model_name='author',
- name='first_name',
+ model_name="author",
+ name="first_name",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='author',
- name='last_name',
+ model_name="author",
+ name="last_name",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='author',
- name='name',
- field=models.CharField(default='Unknown', max_length=255),
+ model_name="author",
+ name="name",
+ field=models.CharField(default="Unknown", max_length=255),
preserve_default=False,
),
migrations.AddField(
- model_name='author',
- name='wikipedia_link',
+ model_name="author",
+ name="wikipedia_link",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='book',
- name='description',
+ model_name="book",
+ name="description",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
- model_name='book',
- name='first_published_date',
+ model_name="book",
+ name="first_published_date",
field=models.DateTimeField(null=True),
),
migrations.AddField(
- model_name='book',
- name='language',
+ model_name="book",
+ name="language",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='book',
- name='last_sync_date',
+ model_name="book",
+ name="last_sync_date",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
- model_name='book',
- name='librarything_key',
+ model_name="book",
+ name="librarything_key",
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AddField(
- model_name='book',
- name='local_edits',
+ model_name="book",
+ name="local_edits",
field=models.BooleanField(default=False),
),
migrations.AddField(
- model_name='book',
- name='local_key',
+ model_name="book",
+ name="local_key",
field=models.CharField(default=uuid.uuid4, max_length=255, unique=True),
),
migrations.AddField(
- model_name='book',
- name='misc_identifiers',
+ model_name="book",
+ name="misc_identifiers",
field=JSONField(null=True),
),
migrations.AddField(
- model_name='book',
- name='origin',
+ model_name="book",
+ name="origin",
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AddField(
- model_name='book',
- name='published_date',
+ model_name="book",
+ name="published_date",
field=models.DateTimeField(null=True),
),
migrations.AddField(
- model_name='book',
- name='series',
+ model_name="book",
+ name="series",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='book',
- name='series_number',
+ model_name="book",
+ name="series_number",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='book',
- name='sort_title',
+ model_name="book",
+ name="sort_title",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='book',
- name='subtitle',
+ model_name="book",
+ name="subtitle",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
- model_name='book',
- name='sync',
+ model_name="book",
+ name="sync",
field=models.BooleanField(default=True),
),
migrations.AddField(
- model_name='book',
- name='title',
- field=models.CharField(default='Unknown', max_length=255),
+ model_name="book",
+ name="title",
+ field=models.CharField(default="Unknown", max_length=255),
preserve_default=False,
),
migrations.AlterField(
- model_name='author',
- name='openlibrary_key',
+ model_name="author",
+ name="openlibrary_key",
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AlterField(
- model_name='book',
- name='openlibrary_key',
+ model_name="book",
+ name="openlibrary_key",
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AddField(
- model_name='book',
- name='parent_work',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'),
+ model_name="book",
+ name="parent_work",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Work",
+ ),
),
migrations.CreateModel(
- name='Notification',
+ name="Notification",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('read', models.BooleanField(default=False)),
- ('notification_type', models.CharField(max_length=255)),
- ('related_book', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
- ('related_status', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')),
- ('related_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_user', to=settings.AUTH_USER_MODEL)),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("read", models.BooleanField(default=False)),
+ ("notification_type", models.CharField(max_length=255)),
+ (
+ "related_book",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Book",
+ ),
+ ),
+ (
+ "related_status",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Status",
+ ),
+ ),
+ (
+ "related_user",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="related_user",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.AddField(
- model_name='author',
- name='aliases',
- field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="author",
+ name="aliases",
+ field=bookwyrm.models.fields.ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AddField(
- model_name='user',
- name='manually_approves_followers',
+ model_name="user",
+ name="manually_approves_followers",
field=models.BooleanField(default=False),
),
migrations.AddField(
- model_name='status',
- name='remote_id',
+ model_name="status",
+ name="remote_id",
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.CreateModel(
- name='UserBlocks',
+ name="UserBlocks",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('relationship_id', models.CharField(max_length=100)),
- ('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL)),
- ('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("relationship_id", models.CharField(max_length=100)),
+ (
+ "user_object",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userblocks_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "user_subject",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userblocks_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='UserFollowRequest',
+ name="UserFollowRequest",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('relationship_id', models.CharField(max_length=100)),
- ('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL)),
- ('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("relationship_id", models.CharField(max_length=100)),
+ (
+ "user_object",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollowrequest_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "user_subject",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollowrequest_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='UserFollows',
+ name="UserFollows",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('relationship_id', models.CharField(max_length=100)),
- ('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL)),
- ('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("relationship_id", models.CharField(max_length=100)),
+ (
+ "user_object",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollows_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "user_subject",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollows_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.DeleteModel(
- name='UserRelationship',
+ name="UserRelationship",
),
migrations.AddField(
- model_name='user',
- name='blocks',
- field=models.ManyToManyField(related_name='blocked_by', through='bookwyrm.UserBlocks', to=settings.AUTH_USER_MODEL),
+ model_name="user",
+ name="blocks",
+ field=models.ManyToManyField(
+ related_name="blocked_by",
+ through="bookwyrm.UserBlocks",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AddField(
- model_name='user',
- name='follow_requests',
- field=models.ManyToManyField(related_name='follower_requests', through='bookwyrm.UserFollowRequest', to=settings.AUTH_USER_MODEL),
+ model_name="user",
+ name="follow_requests",
+ field=models.ManyToManyField(
+ related_name="follower_requests",
+ through="bookwyrm.UserFollowRequest",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AddField(
- model_name='user',
- name='following',
- field=models.ManyToManyField(related_name='followers', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL),
+ model_name="user",
+ name="following",
+ field=models.ManyToManyField(
+ related_name="followers",
+ through="bookwyrm.UserFollows",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AddConstraint(
- model_name='userfollows',
- constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollows_unique'),
+ model_name="userfollows",
+ constraint=models.UniqueConstraint(
+ fields=("user_subject", "user_object"), name="userfollows_unique"
+ ),
),
migrations.AddConstraint(
- model_name='userfollowrequest',
- constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollowrequest_unique'),
+ model_name="userfollowrequest",
+ constraint=models.UniqueConstraint(
+ fields=("user_subject", "user_object"), name="userfollowrequest_unique"
+ ),
),
migrations.AddConstraint(
- model_name='userblocks',
- constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userblocks_unique'),
+ model_name="userblocks",
+ constraint=models.UniqueConstraint(
+ fields=("user_subject", "user_object"), name="userblocks_unique"
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='notification_type',
- field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request')], max_length=255),
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ],
+ max_length=255,
+ ),
),
migrations.AddConstraint(
- model_name='notification',
- constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST']), name='notification_type_valid'),
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ notification_type__in=[
+ "FAVORITE",
+ "REPLY",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ ]
+ ),
+ name="notification_type_valid",
+ ),
),
migrations.AddConstraint(
- model_name='userblocks',
- constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userblocks_no_self'),
+ model_name="userblocks",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ _negated=True,
+ user_subject=django.db.models.expressions.F("user_object"),
+ ),
+ name="userblocks_no_self",
+ ),
),
migrations.AddConstraint(
- model_name='userfollowrequest',
- constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollowrequest_no_self'),
+ model_name="userfollowrequest",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ _negated=True,
+ user_subject=django.db.models.expressions.F("user_object"),
+ ),
+ name="userfollowrequest_no_self",
+ ),
),
migrations.AddConstraint(
- model_name='userfollows',
- constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollows_no_self'),
+ model_name="userfollows",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ _negated=True,
+ user_subject=django.db.models.expressions.F("user_object"),
+ ),
+ name="userfollows_no_self",
+ ),
),
migrations.AddField(
- model_name='favorite',
- name='remote_id',
+ model_name="favorite",
+ name="remote_id",
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.CreateModel(
- name='Comment',
+ name="Comment",
fields=[
- ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
- ('name', models.CharField(max_length=255)),
- ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
+ (
+ "status_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Status",
+ ),
+ ),
+ ("name", models.CharField(max_length=255)),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=('bookwyrm.status',),
+ bases=("bookwyrm.status",),
),
migrations.CreateModel(
- name='Connector',
+ name="Connector",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('identifier', models.CharField(max_length=255, unique=True)),
- ('connector_file', models.CharField(choices=[('openlibrary', 'Openlibrary'), ('bookwyrm', 'BookWyrm')], default='openlibrary', max_length=255)),
- ('is_self', models.BooleanField(default=False)),
- ('api_key', models.CharField(max_length=255, null=True)),
- ('base_url', models.CharField(max_length=255)),
- ('covers_url', models.CharField(max_length=255)),
- ('search_url', models.CharField(max_length=255, null=True)),
- ('key_name', models.CharField(max_length=255)),
- ('politeness_delay', models.IntegerField(null=True)),
- ('max_query_count', models.IntegerField(null=True)),
- ('query_count', models.IntegerField(default=0)),
- ('query_count_expiry', models.DateTimeField(auto_now_add=True)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("identifier", models.CharField(max_length=255, unique=True)),
+ (
+ "connector_file",
+ models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("bookwyrm", "BookWyrm"),
+ ],
+ default="openlibrary",
+ max_length=255,
+ ),
+ ),
+ ("is_self", models.BooleanField(default=False)),
+ ("api_key", models.CharField(max_length=255, null=True)),
+ ("base_url", models.CharField(max_length=255)),
+ ("covers_url", models.CharField(max_length=255)),
+ ("search_url", models.CharField(max_length=255, null=True)),
+ ("key_name", models.CharField(max_length=255)),
+ ("politeness_delay", models.IntegerField(null=True)),
+ ("max_query_count", models.IntegerField(null=True)),
+ ("query_count", models.IntegerField(default=0)),
+ ("query_count_expiry", models.DateTimeField(auto_now_add=True)),
],
),
migrations.RenameField(
- model_name='book',
- old_name='local_key',
- new_name='fedireads_key',
+ model_name="book",
+ old_name="local_key",
+ new_name="fedireads_key",
),
migrations.RenameField(
- model_name='book',
- old_name='origin',
- new_name='source_url',
+ model_name="book",
+ old_name="origin",
+ new_name="source_url",
),
migrations.RemoveField(
- model_name='book',
- name='local_edits',
+ model_name="book",
+ name="local_edits",
),
migrations.AddConstraint(
- model_name='connector',
- constraint=models.CheckConstraint(check=models.Q(connector_file__in=bookwyrm.models.connector.ConnectorFiles), name='connector_file_valid'),
+ model_name="connector",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ connector_file__in=bookwyrm.models.connector.ConnectorFiles
+ ),
+ name="connector_file_valid",
+ ),
),
migrations.AddField(
- model_name='book',
- name='connector',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Connector'),
+ model_name="book",
+ name="connector",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Connector",
+ ),
),
migrations.AddField(
- model_name='book',
- name='subject_places',
- field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="book",
+ name="subject_places",
+ field=ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AddField(
- model_name='book',
- name='subjects',
- field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="book",
+ name="subjects",
+ field=ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AddField(
- model_name='edition',
- name='publishers',
- field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="edition",
+ name="publishers",
+ field=ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='connector',
- name='connector_file',
- field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255),
+ model_name="connector",
+ name="connector_file",
+ field=models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("fedireads_connector", "Fedireads Connector"),
+ ],
+ default="openlibrary",
+ max_length=255,
+ ),
),
migrations.RemoveField(
- model_name='connector',
- name='is_self',
+ model_name="connector",
+ name="is_self",
),
migrations.AlterField(
- model_name='connector',
- name='connector_file',
- field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255),
+ model_name="connector",
+ name="connector_file",
+ field=models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("self_connector", "Self Connector"),
+ ("fedireads_connector", "Fedireads Connector"),
+ ],
+ default="openlibrary",
+ max_length=255,
+ ),
),
migrations.AddField(
- model_name='book',
- name='sync_cover',
+ model_name="book",
+ name="sync_cover",
field=models.BooleanField(default=True),
),
migrations.AlterField(
- model_name='author',
- name='born',
+ model_name="author",
+ name="born",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
- model_name='author',
- name='died',
+ model_name="author",
+ name="died",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
- model_name='author',
- name='fedireads_key',
+ model_name="author",
+ name="fedireads_key",
field=models.CharField(default=uuid.uuid4, max_length=255, unique=True),
),
migrations.AlterField(
- model_name='author',
- name='first_name',
+ model_name="author",
+ name="first_name",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='author',
- name='last_name',
+ model_name="author",
+ name="last_name",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='author',
- name='openlibrary_key',
+ model_name="author",
+ name="openlibrary_key",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='book',
- name='first_published_date',
+ model_name="book",
+ name="first_published_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
- model_name='book',
- name='goodreads_key',
+ model_name="book",
+ name="goodreads_key",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='book',
- name='language',
+ model_name="book",
+ name="language",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='book',
- name='librarything_key',
+ model_name="book",
+ name="librarything_key",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='book',
- name='openlibrary_key',
+ model_name="book",
+ name="openlibrary_key",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='book',
- name='published_date',
+ model_name="book",
+ name="published_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
- model_name='book',
- name='sort_title',
+ model_name="book",
+ name="sort_title",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='book',
- name='subtitle',
+ model_name="book",
+ name="subtitle",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='edition',
- name='isbn',
+ model_name="edition",
+ name="isbn",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='edition',
- name='oclc_number',
+ model_name="edition",
+ name="oclc_number",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='edition',
- name='pages',
+ model_name="edition",
+ name="pages",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
- model_name='edition',
- name='physical_format',
+ model_name="edition",
+ name="physical_format",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='work',
- name='lccn',
+ model_name="work",
+ name="lccn",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='federatedserver',
- name='application_version',
+ model_name="federatedserver",
+ name="application_version",
field=models.CharField(max_length=255, null=True),
),
migrations.AlterField(
- model_name='book',
- name='last_sync_date',
+ model_name="book",
+ name="last_sync_date",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
- model_name='status',
- name='published_date',
+ model_name="status",
+ name="published_date",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.CreateModel(
- name='Boost',
+ name="Boost",
fields=[
- ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
+ (
+ "status_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Status",
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=('bookwyrm.status',),
+ bases=("bookwyrm.status",),
),
migrations.RemoveConstraint(
- model_name='notification',
- name='notification_type_valid',
+ model_name="notification",
+ name="notification_type_valid",
),
migrations.AlterField(
- model_name='notification',
- name='notification_type',
- field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost')], max_length=255),
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("BOOST", "Boost"),
+ ],
+ max_length=255,
+ ),
),
migrations.AddConstraint(
- model_name='notification',
- constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST']), name='notification_type_valid'),
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ notification_type__in=[
+ "FAVORITE",
+ "REPLY",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ "BOOST",
+ ]
+ ),
+ name="notification_type_valid",
+ ),
),
migrations.AddField(
- model_name='boost',
- name='boosted_status',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'),
+ model_name="boost",
+ name="boosted_status",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="boosters",
+ to="bookwyrm.Status",
+ ),
),
migrations.RemoveField(
- model_name='book',
- name='language',
+ model_name="book",
+ name="language",
),
migrations.RemoveField(
- model_name='book',
- name='parent_work',
+ model_name="book",
+ name="parent_work",
),
migrations.RemoveField(
- model_name='book',
- name='shelves',
+ model_name="book",
+ name="shelves",
),
migrations.AddField(
- model_name='book',
- name='languages',
- field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="book",
+ name="languages",
+ field=ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AddField(
- model_name='edition',
- name='default',
+ model_name="edition",
+ name="default",
field=models.BooleanField(default=False),
),
migrations.AddField(
- model_name='edition',
- name='parent_work',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'),
+ model_name="edition",
+ name="parent_work",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Work",
+ ),
),
migrations.AddField(
- model_name='edition',
- name='shelves',
- field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Shelf'),
+ model_name="edition",
+ name="shelves",
+ field=models.ManyToManyField(
+ through="bookwyrm.ShelfBook", to="bookwyrm.Shelf"
+ ),
),
migrations.AlterField(
- model_name='comment',
- name='book',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="comment",
+ name="book",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='related_book',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="notification",
+ name="related_book",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Edition",
+ ),
),
migrations.AlterField(
- model_name='review',
- name='book',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="review",
+ name="book",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='shelf',
- name='books',
- field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Edition'),
+ model_name="shelf",
+ name="books",
+ field=models.ManyToManyField(
+ through="bookwyrm.ShelfBook", to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='shelfbook',
- name='book',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="shelfbook",
+ name="book",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='status',
- name='mention_books',
- field=models.ManyToManyField(related_name='mention_book', to='bookwyrm.Edition'),
+ model_name="status",
+ name="mention_books",
+ field=models.ManyToManyField(
+ related_name="mention_book", to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='tag',
- name='book',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="tag",
+ name="book",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.RemoveField(
- model_name='comment',
- name='name',
+ model_name="comment",
+ name="name",
),
migrations.AlterField(
- model_name='review',
- name='rating',
- field=models.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]),
+ model_name="review",
+ name="rating",
+ field=models.IntegerField(
+ blank=True,
+ default=None,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(5),
+ ],
+ ),
),
migrations.AlterField(
- model_name='review',
- name='name',
+ model_name="review",
+ name="name",
field=models.CharField(max_length=255, null=True),
),
migrations.CreateModel(
- name='Quotation',
+ name="Quotation",
fields=[
- ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
- ('quote', models.TextField()),
- ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
+ (
+ "status_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Status",
+ ),
+ ),
+ ("quote", models.TextField()),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Edition",
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=('bookwyrm.status',),
+ bases=("bookwyrm.status",),
),
migrations.CreateModel(
- name='ReadThrough',
+ name="ReadThrough",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('pages_read', models.IntegerField(blank=True, null=True)),
- ('start_date', models.DateTimeField(blank=True, null=True)),
- ('finish_date', models.DateTimeField(blank=True, null=True)),
- ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("pages_read", models.IntegerField(blank=True, null=True)),
+ ("start_date", models.DateTimeField(blank=True, null=True)),
+ ("finish_date", models.DateTimeField(blank=True, null=True)),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Book"
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
migrations.CreateModel(
- name='ImportItem',
+ name="ImportItem",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('data', JSONField()),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("data", JSONField()),
],
),
migrations.CreateModel(
- name='ImportJob',
+ name="ImportJob",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(default=django.utils.timezone.now)),
- ('task_id', models.CharField(max_length=100, null=True)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "created_date",
+ models.DateTimeField(default=django.utils.timezone.now),
+ ),
+ ("task_id", models.CharField(max_length=100, null=True)),
],
),
migrations.RemoveConstraint(
- model_name='notification',
- name='notification_type_valid',
+ model_name="notification",
+ name="notification_type_valid",
),
migrations.AlterField(
- model_name='notification',
- name='notification_type',
- field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT_RESULT', 'Import Result')], max_length=255),
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("BOOST", "Boost"),
+ ("IMPORT_RESULT", "Import Result"),
+ ],
+ max_length=255,
+ ),
),
migrations.AddConstraint(
- model_name='notification',
- constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT_RESULT']), name='notification_type_valid'),
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ notification_type__in=[
+ "FAVORITE",
+ "REPLY",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ "BOOST",
+ "IMPORT_RESULT",
+ ]
+ ),
+ name="notification_type_valid",
+ ),
),
migrations.AddField(
- model_name='importjob',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ model_name="importjob",
+ name="user",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AddField(
- model_name='importitem',
- name='book',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookwyrm.Book'),
+ model_name="importitem",
+ name="book",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="bookwyrm.Book",
+ ),
),
migrations.AddField(
- model_name='importitem',
- name='job',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='bookwyrm.ImportJob'),
+ model_name="importitem",
+ name="job",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="items",
+ to="bookwyrm.ImportJob",
+ ),
),
migrations.RemoveConstraint(
- model_name='notification',
- name='notification_type_valid',
+ model_name="notification",
+ name="notification_type_valid",
),
migrations.AddField(
- model_name='importitem',
- name='fail_reason',
+ model_name="importitem",
+ name="fail_reason",
field=models.TextField(null=True),
),
migrations.AddField(
- model_name='importitem',
- name='index',
+ model_name="importitem",
+ name="index",
field=models.IntegerField(default=1),
preserve_default=False,
),
migrations.AddField(
- model_name='notification',
- name='related_import',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ImportJob'),
+ model_name="notification",
+ name="related_import",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.ImportJob",
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='notification_type',
- field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import')], max_length=255),
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("BOOST", "Boost"),
+ ("IMPORT", "Import"),
+ ],
+ max_length=255,
+ ),
),
migrations.AddConstraint(
- model_name='notification',
- constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT']), name='notification_type_valid'),
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ notification_type__in=[
+ "FAVORITE",
+ "REPLY",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ "BOOST",
+ "IMPORT",
+ ]
+ ),
+ name="notification_type_valid",
+ ),
),
migrations.RenameField(
- model_name='edition',
- old_name='isbn',
- new_name='isbn_13',
+ model_name="edition",
+ old_name="isbn",
+ new_name="isbn_13",
),
migrations.AddField(
- model_name='book',
- name='author_text',
+ model_name="book",
+ name="author_text",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='edition',
- name='asin',
+ model_name="edition",
+ name="asin",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='edition',
- name='isbn_10',
+ model_name="edition",
+ name="isbn_10",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='connector',
- name='books_url',
- field=models.CharField(default='https://openlibrary.org', max_length=255),
+ model_name="connector",
+ name="books_url",
+ field=models.CharField(default="https://openlibrary.org", max_length=255),
preserve_default=False,
),
migrations.AddField(
- model_name='connector',
- name='local',
+ model_name="connector",
+ name="local",
field=models.BooleanField(default=False),
),
migrations.AddField(
- model_name='connector',
- name='name',
+ model_name="connector",
+ name="name",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='connector',
- name='priority',
+ model_name="connector",
+ name="priority",
field=models.IntegerField(default=2),
),
migrations.AlterField(
- model_name='connector',
- name='connector_file',
- field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], max_length=255),
+ model_name="connector",
+ name="connector_file",
+ field=models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("self_connector", "Self Connector"),
+ ("fedireads_connector", "Fedireads Connector"),
+ ],
+ max_length=255,
+ ),
),
migrations.RemoveField(
- model_name='author',
- name='fedireads_key',
+ model_name="author",
+ name="fedireads_key",
),
migrations.RemoveField(
- model_name='book',
- name='fedireads_key',
+ model_name="book",
+ name="fedireads_key",
),
migrations.RemoveField(
- model_name='book',
- name='source_url',
+ model_name="book",
+ name="source_url",
),
migrations.AddField(
- model_name='author',
- name='last_sync_date',
+ model_name="author",
+ name="last_sync_date",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
- model_name='author',
- name='sync',
+ model_name="author",
+ name="sync",
field=models.BooleanField(default=True),
),
migrations.AddField(
- model_name='book',
- name='remote_id',
+ model_name="book",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='author',
- name='remote_id',
+ model_name="author",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.RemoveField(
- model_name='book',
- name='misc_identifiers',
+ model_name="book",
+ name="misc_identifiers",
),
migrations.RemoveField(
- model_name='connector',
- name='key_name',
+ model_name="connector",
+ name="key_name",
),
migrations.RemoveField(
- model_name='user',
- name='actor',
+ model_name="user",
+ name="actor",
),
migrations.AddField(
- model_name='connector',
- name='remote_id',
+ model_name="connector",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='federatedserver',
- name='remote_id',
+ model_name="federatedserver",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='notification',
- name='remote_id',
+ model_name="notification",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='readthrough',
- name='remote_id',
+ model_name="readthrough",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='shelf',
- name='remote_id',
+ model_name="shelf",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='shelfbook',
- name='remote_id',
+ model_name="shelfbook",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='tag',
- name='remote_id',
+ model_name="tag",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='userblocks',
- name='remote_id',
+ model_name="userblocks",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='userfollowrequest',
- name='remote_id',
+ model_name="userfollowrequest",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='userfollows',
- name='remote_id',
+ model_name="userfollows",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AlterField(
- model_name='favorite',
- name='remote_id',
+ model_name="favorite",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AlterField(
- model_name='status',
- name='remote_id',
+ model_name="status",
+ name="remote_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='user',
- name='remote_id',
+ model_name="user",
+ name="remote_id",
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.CreateModel(
- name='SiteInvite',
+ name="SiteInvite",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)),
- ('expiry', models.DateTimeField(blank=True, null=True)),
- ('use_limit', models.IntegerField(blank=True, null=True)),
- ('times_used', models.IntegerField(default=0)),
- ('user', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "code",
+ models.CharField(
+ default=bookwyrm.models.site.new_access_code, max_length=32
+ ),
+ ),
+ ("expiry", models.DateTimeField(blank=True, null=True)),
+ ("use_limit", models.IntegerField(blank=True, null=True)),
+ ("times_used", models.IntegerField(default=0)),
+ (
+ "user",
+ models.ForeignKey(
+ default=1,
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
),
migrations.RemoveField(
- model_name='status',
- name='activity_type',
+ model_name="status",
+ name="activity_type",
),
migrations.RemoveField(
- model_name='status',
- name='status_type',
+ model_name="status",
+ name="status_type",
),
migrations.RenameField(
- model_name='user',
- old_name='fedireads_user',
- new_name='bookwyrm_user',
+ model_name="user",
+ old_name="fedireads_user",
+ new_name="bookwyrm_user",
),
migrations.AlterField(
- model_name='connector',
- name='connector_file',
- field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'BookWyrm Connector')], max_length=255),
+ model_name="connector",
+ name="connector_file",
+ field=models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("self_connector", "Self Connector"),
+ ("bookwyrm_connector", "BookWyrm Connector"),
+ ],
+ max_length=255,
+ ),
),
migrations.AlterField(
- model_name='connector',
- name='connector_file',
- field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'Bookwyrm Connector')], max_length=255),
+ model_name="connector",
+ name="connector_file",
+ field=models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("self_connector", "Self Connector"),
+ ("bookwyrm_connector", "Bookwyrm Connector"),
+ ],
+ max_length=255,
+ ),
),
migrations.CreateModel(
- name='GeneratedStatus',
+ name="GeneratedStatus",
fields=[
- ('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
+ (
+ "status_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Status",
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=('bookwyrm.status',),
+ bases=("bookwyrm.status",),
),
migrations.CreateModel(
- name='PasswordReset',
+ name="PasswordReset",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)),
- ('expiry', models.DateTimeField(default=bookwyrm.models.site.get_passowrd_reset_expiry)),
- ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "code",
+ models.CharField(
+ default=bookwyrm.models.site.new_access_code, max_length=32
+ ),
+ ),
+ (
+ "expiry",
+ models.DateTimeField(
+ default=bookwyrm.models.site.get_passowrd_reset_expiry
+ ),
+ ),
+ (
+ "user",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
),
migrations.AlterField(
- model_name='user',
- name='email',
+ model_name="user",
+ name="email",
field=models.EmailField(max_length=254, unique=True),
),
migrations.CreateModel(
- name='SiteSettings',
+ name="SiteSettings",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('name', models.CharField(default='BookWyrm', max_length=100)),
- ('instance_description', models.TextField(default='This instance has no description.')),
- ('code_of_conduct', models.TextField(default='Add a code of conduct here.')),
- ('allow_registration', models.BooleanField(default=True)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("name", models.CharField(default="BookWyrm", max_length=100)),
+ (
+ "instance_description",
+ models.TextField(default="This instance has no description."),
+ ),
+ (
+ "code_of_conduct",
+ models.TextField(default="Add a code of conduct here."),
+ ),
+ ("allow_registration", models.BooleanField(default=True)),
],
),
migrations.AlterField(
- model_name='user',
- name='email',
- field=models.EmailField(blank=True, max_length=254, verbose_name='email address'),
+ model_name="user",
+ name="email",
+ field=models.EmailField(
+ blank=True, max_length=254, verbose_name="email address"
+ ),
),
migrations.AddField(
- model_name='status',
- name='deleted',
+ model_name="status",
+ name="deleted",
field=models.BooleanField(default=False),
),
migrations.AddField(
- model_name='status',
- name='deleted_date',
+ model_name="status",
+ name="deleted_date",
field=models.DateTimeField(),
),
- django.contrib.postgres.operations.TrigramExtension(
+ django.contrib.postgres.operations.TrigramExtension(),
+ migrations.RemoveField(
+ model_name="userblocks",
+ name="relationship_id",
),
migrations.RemoveField(
- model_name='userblocks',
- name='relationship_id',
+ model_name="userfollowrequest",
+ name="relationship_id",
),
migrations.RemoveField(
- model_name='userfollowrequest',
- name='relationship_id',
- ),
- migrations.RemoveField(
- model_name='userfollows',
- name='relationship_id',
+ model_name="userfollows",
+ name="relationship_id",
),
migrations.AlterField(
- model_name='status',
- name='deleted_date',
+ model_name="status",
+ name="deleted_date",
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
- model_name='status',
- name='privacy',
- field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
+ model_name="status",
+ name="privacy",
+ field=models.CharField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
),
migrations.AddField(
- model_name='importjob',
- name='include_reviews',
+ model_name="importjob",
+ name="include_reviews",
field=models.BooleanField(default=True),
),
migrations.AddField(
- model_name='importjob',
- name='privacy',
- field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
+ model_name="importjob",
+ name="privacy",
+ field=models.CharField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
),
migrations.AlterField(
- model_name='user',
- name='federated_server',
- field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.FederatedServer'),
+ model_name="user",
+ name="federated_server",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.FederatedServer",
+ ),
),
migrations.RenameModel(
- old_name='GeneratedStatus',
- new_name='GeneratedNote',
+ old_name="GeneratedStatus",
+ new_name="GeneratedNote",
),
migrations.AlterField(
- model_name='connector',
- name='api_key',
+ model_name="connector",
+ name="api_key",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='connector',
- name='max_query_count',
+ model_name="connector",
+ name="max_query_count",
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
- model_name='connector',
- name='name',
+ model_name="connector",
+ name="name",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
- model_name='connector',
- name='politeness_delay',
+ model_name="connector",
+ name="politeness_delay",
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
- model_name='connector',
- name='search_url',
+ model_name="connector",
+ name="search_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='user',
- name='last_active_date',
+ model_name="user",
+ name="last_active_date",
field=models.DateTimeField(auto_now=True),
),
migrations.RemoveConstraint(
- model_name='notification',
- name='notification_type_valid',
+ model_name="notification",
+ name="notification_type_valid",
),
migrations.AlterField(
- model_name='notification',
- name='notification_type',
- field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import')], max_length=255),
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("BOOST", "Boost"),
+ ("IMPORT", "Import"),
+ ],
+ max_length=255,
+ ),
),
migrations.AddConstraint(
- model_name='notification',
- constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT']), name='notification_type_valid'),
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ notification_type__in=[
+ "FAVORITE",
+ "REPLY",
+ "MENTION",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ "BOOST",
+ "IMPORT",
+ ]
+ ),
+ name="notification_type_valid",
+ ),
),
]
diff --git a/bookwyrm/migrations/0007_auto_20201103_0014.py b/bookwyrm/migrations/0007_auto_20201103_0014.py
index bf0a12eb..116c97a3 100644
--- a/bookwyrm/migrations/0007_auto_20201103_0014.py
+++ b/bookwyrm/migrations/0007_auto_20201103_0014.py
@@ -8,13 +8,15 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0006_auto_20200221_1702_squashed_0064_merge_20201101_1913'),
+ ("bookwyrm", "0006_auto_20200221_1702_squashed_0064_merge_20201101_1913"),
]
operations = [
migrations.AlterField(
- model_name='siteinvite',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ model_name="siteinvite",
+ name="user",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+ ),
),
]
diff --git a/bookwyrm/migrations/0008_work_default_edition.py b/bookwyrm/migrations/0008_work_default_edition.py
index da1f959e..787e3776 100644
--- a/bookwyrm/migrations/0008_work_default_edition.py
+++ b/bookwyrm/migrations/0008_work_default_edition.py
@@ -6,8 +6,8 @@ import django.db.models.deletion
def set_default_edition(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- works = app_registry.get_model('bookwyrm', 'Work').objects.using(db_alias)
- editions = app_registry.get_model('bookwyrm', 'Edition').objects.using(db_alias)
+ works = app_registry.get_model("bookwyrm", "Work").objects.using(db_alias)
+ editions = app_registry.get_model("bookwyrm", "Edition").objects.using(db_alias)
for work in works:
ed = editions.filter(parent_work=work, default=True).first()
if not ed:
@@ -15,21 +15,26 @@ def set_default_edition(app_registry, schema_editor):
work.default_edition = ed
work.save()
+
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0007_auto_20201103_0014'),
+ ("bookwyrm", "0007_auto_20201103_0014"),
]
operations = [
migrations.AddField(
- model_name='work',
- name='default_edition',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="work",
+ name="default_edition",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Edition",
+ ),
),
migrations.RunPython(set_default_edition),
migrations.RemoveField(
- model_name='edition',
- name='default',
+ model_name="edition",
+ name="default",
),
]
diff --git a/bookwyrm/migrations/0009_shelf_privacy.py b/bookwyrm/migrations/0009_shelf_privacy.py
index 8232c2ed..63566104 100644
--- a/bookwyrm/migrations/0009_shelf_privacy.py
+++ b/bookwyrm/migrations/0009_shelf_privacy.py
@@ -6,13 +6,22 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0008_work_default_edition'),
+ ("bookwyrm", "0008_work_default_edition"),
]
operations = [
migrations.AddField(
- model_name='shelf',
- name='privacy',
- field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
+ model_name="shelf",
+ name="privacy",
+ field=models.CharField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
),
]
diff --git a/bookwyrm/migrations/0010_importjob_retry.py b/bookwyrm/migrations/0010_importjob_retry.py
index 21296cc4..b3cc371b 100644
--- a/bookwyrm/migrations/0010_importjob_retry.py
+++ b/bookwyrm/migrations/0010_importjob_retry.py
@@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0009_shelf_privacy'),
+ ("bookwyrm", "0009_shelf_privacy"),
]
operations = [
migrations.AddField(
- model_name='importjob',
- name='retry',
+ model_name="importjob",
+ name="retry",
field=models.BooleanField(default=False),
),
]
diff --git a/bookwyrm/migrations/0011_auto_20201113_1727.py b/bookwyrm/migrations/0011_auto_20201113_1727.py
index 15e853a3..f4ea55c5 100644
--- a/bookwyrm/migrations/0011_auto_20201113_1727.py
+++ b/bookwyrm/migrations/0011_auto_20201113_1727.py
@@ -2,9 +2,10 @@
from django.db import migrations, models
+
def set_origin_id(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- books = app_registry.get_model('bookwyrm', 'Book').objects.using(db_alias)
+ books = app_registry.get_model("bookwyrm", "Book").objects.using(db_alias)
for book in books:
book.origin_id = book.remote_id
# the remote_id will be set automatically
@@ -15,18 +16,18 @@ def set_origin_id(app_registry, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0010_importjob_retry'),
+ ("bookwyrm", "0010_importjob_retry"),
]
operations = [
migrations.AddField(
- model_name='author',
- name='origin_id',
+ model_name="author",
+ name="origin_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
- model_name='book',
- name='origin_id',
+ model_name="book",
+ name="origin_id",
field=models.CharField(max_length=255, null=True),
),
migrations.RunPython(set_origin_id),
diff --git a/bookwyrm/migrations/0012_attachment.py b/bookwyrm/migrations/0012_attachment.py
index 49553851..5188b463 100644
--- a/bookwyrm/migrations/0012_attachment.py
+++ b/bookwyrm/migrations/0012_attachment.py
@@ -7,23 +7,41 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0011_auto_20201113_1727'),
+ ("bookwyrm", "0011_auto_20201113_1727"),
]
operations = [
migrations.CreateModel(
- name='Attachment',
+ name="Attachment",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('remote_id', models.CharField(max_length=255, null=True)),
- ('image', models.ImageField(blank=True, null=True, upload_to='status/')),
- ('caption', models.TextField(blank=True, null=True)),
- ('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status')),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("remote_id", models.CharField(max_length=255, null=True)),
+ (
+ "image",
+ models.ImageField(blank=True, null=True, upload_to="status/"),
+ ),
+ ("caption", models.TextField(blank=True, null=True)),
+ (
+ "status",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="attachments",
+ to="bookwyrm.Status",
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
]
diff --git a/bookwyrm/migrations/0012_progressupdate.py b/bookwyrm/migrations/0012_progressupdate.py
index 13141971..566556b7 100644
--- a/bookwyrm/migrations/0012_progressupdate.py
+++ b/bookwyrm/migrations/0012_progressupdate.py
@@ -8,24 +8,51 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0011_auto_20201113_1727'),
+ ("bookwyrm", "0011_auto_20201113_1727"),
]
operations = [
migrations.CreateModel(
- name='ProgressUpdate',
+ name="ProgressUpdate",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('remote_id', models.CharField(max_length=255, null=True)),
- ('progress', models.IntegerField()),
- ('mode', models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3)),
- ('readthrough', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ReadThrough')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("remote_id", models.CharField(max_length=255, null=True)),
+ ("progress", models.IntegerField()),
+ (
+ "mode",
+ models.CharField(
+ choices=[("PG", "page"), ("PCT", "percent")],
+ default="PG",
+ max_length=3,
+ ),
+ ),
+ (
+ "readthrough",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.ReadThrough",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
),
]
diff --git a/bookwyrm/migrations/0013_book_origin_id.py b/bookwyrm/migrations/0013_book_origin_id.py
index 581a2406..08cf7bee 100644
--- a/bookwyrm/migrations/0013_book_origin_id.py
+++ b/bookwyrm/migrations/0013_book_origin_id.py
@@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0012_attachment'),
+ ("bookwyrm", "0012_attachment"),
]
operations = [
migrations.AlterField(
- model_name='book',
- name='origin_id',
+ model_name="book",
+ name="origin_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]
diff --git a/bookwyrm/migrations/0014_auto_20201128_0118.py b/bookwyrm/migrations/0014_auto_20201128_0118.py
index babdd780..2626b965 100644
--- a/bookwyrm/migrations/0014_auto_20201128_0118.py
+++ b/bookwyrm/migrations/0014_auto_20201128_0118.py
@@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0013_book_origin_id'),
+ ("bookwyrm", "0013_book_origin_id"),
]
operations = [
migrations.RenameModel(
- old_name='Attachment',
- new_name='Image',
+ old_name="Attachment",
+ new_name="Image",
),
]
diff --git a/bookwyrm/migrations/0014_merge_20201128_0007.py b/bookwyrm/migrations/0014_merge_20201128_0007.py
index e811fa7f..ce6bb5c0 100644
--- a/bookwyrm/migrations/0014_merge_20201128_0007.py
+++ b/bookwyrm/migrations/0014_merge_20201128_0007.py
@@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0013_book_origin_id'),
- ('bookwyrm', '0012_progressupdate'),
+ ("bookwyrm", "0013_book_origin_id"),
+ ("bookwyrm", "0012_progressupdate"),
]
- operations = [
- ]
+ operations = []
diff --git a/bookwyrm/migrations/0015_auto_20201128_0349.py b/bookwyrm/migrations/0015_auto_20201128_0349.py
index 52b15518..f4454c5d 100644
--- a/bookwyrm/migrations/0015_auto_20201128_0349.py
+++ b/bookwyrm/migrations/0015_auto_20201128_0349.py
@@ -7,13 +7,18 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0014_auto_20201128_0118'),
+ ("bookwyrm", "0014_auto_20201128_0118"),
]
operations = [
migrations.AlterField(
- model_name='image',
- name='status',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status'),
+ model_name="image",
+ name="status",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="attachments",
+ to="bookwyrm.Status",
+ ),
),
]
diff --git a/bookwyrm/migrations/0015_auto_20201128_0734.py b/bookwyrm/migrations/0015_auto_20201128_0734.py
index c6eb7815..efbad610 100644
--- a/bookwyrm/migrations/0015_auto_20201128_0734.py
+++ b/bookwyrm/migrations/0015_auto_20201128_0734.py
@@ -6,18 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0014_merge_20201128_0007'),
+ ("bookwyrm", "0014_merge_20201128_0007"),
]
operations = [
migrations.RenameField(
- model_name='readthrough',
- old_name='pages_read',
- new_name='progress',
+ model_name="readthrough",
+ old_name="pages_read",
+ new_name="progress",
),
migrations.AddField(
- model_name='readthrough',
- name='progress_mode',
- field=models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3),
+ model_name="readthrough",
+ name="progress_mode",
+ field=models.CharField(
+ choices=[("PG", "page"), ("PCT", "percent")], default="PG", max_length=3
+ ),
),
]
diff --git a/bookwyrm/migrations/0016_auto_20201129_0304.py b/bookwyrm/migrations/0016_auto_20201129_0304.py
index 1e715969..ef2cbe0f 100644
--- a/bookwyrm/migrations/0016_auto_20201129_0304.py
+++ b/bookwyrm/migrations/0016_auto_20201129_0304.py
@@ -5,58 +5,101 @@ from django.db import migrations, models
import django.db.models.deletion
from django.contrib.postgres.fields import ArrayField
+
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0015_auto_20201128_0349'),
+ ("bookwyrm", "0015_auto_20201128_0349"),
]
operations = [
migrations.AlterField(
- model_name='book',
- name='subject_places',
- field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
+ model_name="book",
+ name="subject_places",
+ field=ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ null=True,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='book',
- name='subjects',
- field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
+ model_name="book",
+ name="subjects",
+ field=ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ null=True,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='parent_work',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'),
+ model_name="edition",
+ name="parent_work",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="editions",
+ to="bookwyrm.Work",
+ ),
),
migrations.AlterField(
- model_name='tag',
- name='name',
+ model_name="tag",
+ name="name",
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterUniqueTogether(
- name='tag',
+ name="tag",
unique_together=set(),
),
migrations.RemoveField(
- model_name='tag',
- name='book',
+ model_name="tag",
+ name="book",
),
migrations.RemoveField(
- model_name='tag',
- name='user',
+ model_name="tag",
+ name="user",
),
migrations.CreateModel(
- name='UserTag',
+ name="UserTag",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('remote_id', models.CharField(max_length=255, null=True)),
- ('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
- ('tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag')),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ ("remote_id", models.CharField(max_length=255, null=True)),
+ (
+ "book",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Edition",
+ ),
+ ),
+ (
+ "tag",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Tag"
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'unique_together': {('user', 'book', 'tag')},
+ "unique_together": {("user", "book", "tag")},
},
),
]
diff --git a/bookwyrm/migrations/0016_auto_20201211_2026.py b/bookwyrm/migrations/0016_auto_20201211_2026.py
index 46b6140c..3793f90b 100644
--- a/bookwyrm/migrations/0016_auto_20201211_2026.py
+++ b/bookwyrm/migrations/0016_auto_20201211_2026.py
@@ -6,23 +6,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0015_auto_20201128_0349'),
+ ("bookwyrm", "0015_auto_20201128_0349"),
]
operations = [
migrations.AddField(
- model_name='sitesettings',
- name='admin_email',
+ model_name="sitesettings",
+ name="admin_email",
field=models.EmailField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='sitesettings',
- name='support_link',
+ model_name="sitesettings",
+ name="support_link",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
- model_name='sitesettings',
- name='support_title',
+ model_name="sitesettings",
+ name="support_title",
field=models.CharField(blank=True, max_length=100, null=True),
),
]
diff --git a/bookwyrm/migrations/0017_auto_20201130_1819.py b/bookwyrm/migrations/0017_auto_20201130_1819.py
index 0775269b..f6478e0a 100644
--- a/bookwyrm/migrations/0017_auto_20201130_1819.py
+++ b/bookwyrm/migrations/0017_auto_20201130_1819.py
@@ -6,184 +6,296 @@ from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
+
def copy_rsa_keys(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- users = app_registry.get_model('bookwyrm', 'User')
- keypair = app_registry.get_model('bookwyrm', 'KeyPair')
+ users = app_registry.get_model("bookwyrm", "User")
+ keypair = app_registry.get_model("bookwyrm", "KeyPair")
for user in users.objects.using(db_alias):
if user.public_key or user.private_key:
user.key_pair = keypair.objects.create(
- remote_id='%s/#main-key' % user.remote_id,
+ remote_id="%s/#main-key" % user.remote_id,
private_key=user.private_key,
- public_key=user.public_key
+ public_key=user.public_key,
)
user.save()
+
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0016_auto_20201129_0304'),
+ ("bookwyrm", "0016_auto_20201129_0304"),
]
operations = [
migrations.CreateModel(
- name='KeyPair',
+ name="KeyPair",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
- ('private_key', models.TextField(blank=True, null=True)),
- ('public_key', bookwyrm.models.fields.TextField(blank=True, null=True)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("private_key", models.TextField(blank=True, null=True)),
+ ("public_key", bookwyrm.models.fields.TextField(blank=True, null=True)),
],
options={
- 'abstract': False,
+ "abstract": False,
},
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.AddField(
- model_name='user',
- name='followers',
- field=bookwyrm.models.fields.ManyToManyField(related_name='following', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL),
+ model_name="user",
+ name="followers",
+ field=bookwyrm.models.fields.ManyToManyField(
+ related_name="following",
+ through="bookwyrm.UserFollows",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='author',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="author",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='book',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="book",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='connector',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="connector",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='favorite',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="favorite",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='federatedserver',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="federatedserver",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='image',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="image",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="notification",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='readthrough',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="readthrough",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='shelf',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="shelf",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='shelfbook',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="shelfbook",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='status',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="status",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='tag',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="tag",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='user',
- name='avatar',
- field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='avatars/'),
+ model_name="user",
+ name="avatar",
+ field=bookwyrm.models.fields.ImageField(
+ blank=True, null=True, upload_to="avatars/"
+ ),
),
migrations.AlterField(
- model_name='user',
- name='bookwyrm_user',
+ model_name="user",
+ name="bookwyrm_user",
field=bookwyrm.models.fields.BooleanField(default=True),
),
migrations.AlterField(
- model_name='user',
- name='inbox',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="user",
+ name="inbox",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='user',
- name='local',
+ model_name="user",
+ name="local",
field=models.BooleanField(default=False),
),
migrations.AlterField(
- model_name='user',
- name='manually_approves_followers',
+ model_name="user",
+ name="manually_approves_followers",
field=bookwyrm.models.fields.BooleanField(default=False),
),
migrations.AlterField(
- model_name='user',
- name='name',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True),
+ model_name="user",
+ name="name",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=100, null=True
+ ),
),
migrations.AlterField(
- model_name='user',
- name='outbox',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="user",
+ name="outbox",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='user',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="user",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='user',
- name='shared_inbox',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="user",
+ name="shared_inbox",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='user',
- name='summary',
+ model_name="user",
+ name="summary",
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
- model_name='user',
- name='username',
+ model_name="user",
+ name="username",
field=bookwyrm.models.fields.UsernameField(),
),
migrations.AlterField(
- model_name='userblocks',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="userblocks",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='userfollowrequest',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="userfollowrequest",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='userfollows',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="userfollows",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='usertag',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="usertag",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AddField(
- model_name='user',
- name='key_pair',
- field=bookwyrm.models.fields.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owner', to='bookwyrm.KeyPair'),
+ model_name="user",
+ name="key_pair",
+ field=bookwyrm.models.fields.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="owner",
+ to="bookwyrm.KeyPair",
+ ),
),
migrations.RunPython(copy_rsa_keys),
]
diff --git a/bookwyrm/migrations/0017_auto_20201212_0059.py b/bookwyrm/migrations/0017_auto_20201212_0059.py
index c9e3fcf4..34d27a1f 100644
--- a/bookwyrm/migrations/0017_auto_20201212_0059.py
+++ b/bookwyrm/migrations/0017_auto_20201212_0059.py
@@ -7,13 +7,15 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0016_auto_20201211_2026'),
+ ("bookwyrm", "0016_auto_20201211_2026"),
]
operations = [
migrations.AlterField(
- model_name='readthrough',
- name='book',
- field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="readthrough",
+ name="book",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
]
diff --git a/bookwyrm/migrations/0018_auto_20201130_1832.py b/bookwyrm/migrations/0018_auto_20201130_1832.py
index 278446cf..579b09f2 100644
--- a/bookwyrm/migrations/0018_auto_20201130_1832.py
+++ b/bookwyrm/migrations/0018_auto_20201130_1832.py
@@ -6,20 +6,20 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0017_auto_20201130_1819'),
+ ("bookwyrm", "0017_auto_20201130_1819"),
]
operations = [
migrations.RemoveField(
- model_name='user',
- name='following',
+ model_name="user",
+ name="following",
),
migrations.RemoveField(
- model_name='user',
- name='private_key',
+ model_name="user",
+ name="private_key",
),
migrations.RemoveField(
- model_name='user',
- name='public_key',
+ model_name="user",
+ name="public_key",
),
]
diff --git a/bookwyrm/migrations/0019_auto_20201130_1939.py b/bookwyrm/migrations/0019_auto_20201130_1939.py
index 11cf6a3b..e5e7674a 100644
--- a/bookwyrm/migrations/0019_auto_20201130_1939.py
+++ b/bookwyrm/migrations/0019_auto_20201130_1939.py
@@ -3,34 +3,36 @@
import bookwyrm.models.fields
from django.db import migrations
+
def update_notnull(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- users = app_registry.get_model('bookwyrm', 'User')
+ users = app_registry.get_model("bookwyrm", "User")
for user in users.objects.using(db_alias):
if user.name and user.summary:
continue
if not user.summary:
- user.summary = ''
+ user.summary = ""
if not user.name:
- user.name = ''
+ user.name = ""
user.save()
+
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0018_auto_20201130_1832'),
+ ("bookwyrm", "0018_auto_20201130_1832"),
]
operations = [
migrations.RunPython(update_notnull),
migrations.AlterField(
- model_name='user',
- name='name',
- field=bookwyrm.models.fields.CharField(default='', max_length=100),
+ model_name="user",
+ name="name",
+ field=bookwyrm.models.fields.CharField(default="", max_length=100),
),
migrations.AlterField(
- model_name='user',
- name='summary',
- field=bookwyrm.models.fields.TextField(default=''),
+ model_name="user",
+ name="summary",
+ field=bookwyrm.models.fields.TextField(default=""),
),
]
diff --git a/bookwyrm/migrations/0020_auto_20201208_0213.py b/bookwyrm/migrations/0020_auto_20201208_0213.py
index 9c5345c7..79d9e73d 100644
--- a/bookwyrm/migrations/0020_auto_20201208_0213.py
+++ b/bookwyrm/migrations/0020_auto_20201208_0213.py
@@ -11,343 +11,497 @@ import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0019_auto_20201130_1939'),
+ ("bookwyrm", "0019_auto_20201130_1939"),
]
operations = [
migrations.AlterField(
- model_name='author',
- name='aliases',
- field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="author",
+ name="aliases",
+ field=bookwyrm.models.fields.ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='author',
- name='bio',
+ model_name="author",
+ name="bio",
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
- model_name='author',
- name='born',
+ model_name="author",
+ name="born",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
- model_name='author',
- name='died',
+ model_name="author",
+ name="died",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
- model_name='author',
- name='name',
+ model_name="author",
+ name="name",
field=bookwyrm.models.fields.CharField(max_length=255),
),
migrations.AlterField(
- model_name='author',
- name='openlibrary_key',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="author",
+ name="openlibrary_key",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='author',
- name='wikipedia_link',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="author",
+ name="wikipedia_link",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='authors',
- field=bookwyrm.models.fields.ManyToManyField(to='bookwyrm.Author'),
+ model_name="book",
+ name="authors",
+ field=bookwyrm.models.fields.ManyToManyField(to="bookwyrm.Author"),
),
migrations.AlterField(
- model_name='book',
- name='cover',
- field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='covers/'),
+ model_name="book",
+ name="cover",
+ field=bookwyrm.models.fields.ImageField(
+ blank=True, null=True, upload_to="covers/"
+ ),
),
migrations.AlterField(
- model_name='book',
- name='description',
+ model_name="book",
+ name="description",
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
- model_name='book',
- name='first_published_date',
+ model_name="book",
+ name="first_published_date",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
- model_name='book',
- name='goodreads_key',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="book",
+ name="goodreads_key",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='languages',
- field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="book",
+ name="languages",
+ field=bookwyrm.models.fields.ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='book',
- name='librarything_key',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="book",
+ name="librarything_key",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='openlibrary_key',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="book",
+ name="openlibrary_key",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='published_date',
+ model_name="book",
+ name="published_date",
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
- model_name='book',
- name='series',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="book",
+ name="series",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='series_number',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="book",
+ name="series_number",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='sort_title',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="book",
+ name="sort_title",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='subject_places',
- field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
+ model_name="book",
+ name="subject_places",
+ field=bookwyrm.models.fields.ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ null=True,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='book',
- name='subjects',
- field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
+ model_name="book",
+ name="subjects",
+ field=bookwyrm.models.fields.ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ null=True,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='book',
- name='subtitle',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="book",
+ name="subtitle",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='book',
- name='title',
+ model_name="book",
+ name="title",
field=bookwyrm.models.fields.CharField(max_length=255),
),
migrations.AlterField(
- model_name='boost',
- name='boosted_status',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'),
+ model_name="boost",
+ name="boosted_status",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="boosters",
+ to="bookwyrm.Status",
+ ),
),
migrations.AlterField(
- model_name='comment',
- name='book',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="comment",
+ name="book",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='asin',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="edition",
+ name="asin",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='isbn_10',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="edition",
+ name="isbn_10",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='isbn_13',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="edition",
+ name="isbn_13",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='oclc_number',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="edition",
+ name="oclc_number",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='pages',
+ model_name="edition",
+ name="pages",
field=bookwyrm.models.fields.IntegerField(blank=True, null=True),
),
migrations.AlterField(
- model_name='edition',
- name='parent_work',
- field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'),
+ model_name="edition",
+ name="parent_work",
+ field=bookwyrm.models.fields.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="editions",
+ to="bookwyrm.Work",
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='physical_format',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="edition",
+ name="physical_format",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AlterField(
- model_name='edition',
- name='publishers',
- field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
+ model_name="edition",
+ name="publishers",
+ field=bookwyrm.models.fields.ArrayField(
+ base_field=models.CharField(max_length=255),
+ blank=True,
+ default=list,
+ size=None,
+ ),
),
migrations.AlterField(
- model_name='favorite',
- name='status',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'),
+ model_name="favorite",
+ name="status",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Status"
+ ),
),
migrations.AlterField(
- model_name='favorite',
- name='user',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="favorite",
+ name="user",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AlterField(
- model_name='image',
- name='caption',
+ model_name="image",
+ name="caption",
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
- model_name='image',
- name='image',
- field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='status/'),
+ model_name="image",
+ name="image",
+ field=bookwyrm.models.fields.ImageField(
+ blank=True, null=True, upload_to="status/"
+ ),
),
migrations.AlterField(
- model_name='quotation',
- name='book',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="quotation",
+ name="book",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='quotation',
- name='quote',
+ model_name="quotation",
+ name="quote",
field=bookwyrm.models.fields.TextField(),
),
migrations.AlterField(
- model_name='review',
- name='book',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="review",
+ name="book",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='review',
- name='name',
+ model_name="review",
+ name="name",
field=bookwyrm.models.fields.CharField(max_length=255, null=True),
),
migrations.AlterField(
- model_name='review',
- name='rating',
- field=bookwyrm.models.fields.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]),
+ model_name="review",
+ name="rating",
+ field=bookwyrm.models.fields.IntegerField(
+ blank=True,
+ default=None,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(5),
+ ],
+ ),
),
migrations.AlterField(
- model_name='shelf',
- name='name',
+ model_name="shelf",
+ name="name",
field=bookwyrm.models.fields.CharField(max_length=100),
),
migrations.AlterField(
- model_name='shelf',
- name='privacy',
- field=bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
+ model_name="shelf",
+ name="privacy",
+ field=bookwyrm.models.fields.CharField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
),
migrations.AlterField(
- model_name='shelf',
- name='user',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="shelf",
+ name="user",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AlterField(
- model_name='shelfbook',
- name='added_by',
- field=bookwyrm.models.fields.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="shelfbook",
+ name="added_by",
+ field=bookwyrm.models.fields.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='shelfbook',
- name='book',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="shelfbook",
+ name="book",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='shelfbook',
- name='shelf',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Shelf'),
+ model_name="shelfbook",
+ name="shelf",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Shelf"
+ ),
),
migrations.AlterField(
- model_name='status',
- name='content',
+ model_name="status",
+ name="content",
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
- model_name='status',
- name='mention_books',
- field=bookwyrm.models.fields.TagField(related_name='mention_book', to='bookwyrm.Edition'),
+ model_name="status",
+ name="mention_books",
+ field=bookwyrm.models.fields.TagField(
+ related_name="mention_book", to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='status',
- name='mention_users',
- field=bookwyrm.models.fields.TagField(related_name='mention_user', to=settings.AUTH_USER_MODEL),
+ model_name="status",
+ name="mention_users",
+ field=bookwyrm.models.fields.TagField(
+ related_name="mention_user", to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AlterField(
- model_name='status',
- name='published_date',
- field=bookwyrm.models.fields.DateTimeField(default=django.utils.timezone.now),
+ model_name="status",
+ name="published_date",
+ field=bookwyrm.models.fields.DateTimeField(
+ default=django.utils.timezone.now
+ ),
),
migrations.AlterField(
- model_name='status',
- name='reply_parent',
- field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'),
+ model_name="status",
+ name="reply_parent",
+ field=bookwyrm.models.fields.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Status",
+ ),
),
migrations.AlterField(
- model_name='status',
- name='sensitive',
+ model_name="status",
+ name="sensitive",
field=bookwyrm.models.fields.BooleanField(default=False),
),
migrations.AlterField(
- model_name='status',
- name='user',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="status",
+ name="user",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AlterField(
- model_name='tag',
- name='name',
+ model_name="tag",
+ name="name",
field=bookwyrm.models.fields.CharField(max_length=100, unique=True),
),
migrations.AlterField(
- model_name='userblocks',
- name='user_object',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL),
+ model_name="userblocks",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userblocks_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='userblocks',
- name='user_subject',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL),
+ model_name="userblocks",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userblocks_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='userfollowrequest',
- name='user_object',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL),
+ model_name="userfollowrequest",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollowrequest_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='userfollowrequest',
- name='user_subject',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL),
+ model_name="userfollowrequest",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollowrequest_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='userfollows',
- name='user_object',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL),
+ model_name="userfollows",
+ name="user_object",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollows_user_object",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='userfollows',
- name='user_subject',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL),
+ model_name="userfollows",
+ name="user_subject",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="userfollows_user_subject",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='usertag',
- name='book',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="usertag",
+ name="book",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
+ ),
),
migrations.AlterField(
- model_name='usertag',
- name='tag',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag'),
+ model_name="usertag",
+ name="tag",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Tag"
+ ),
),
migrations.AlterField(
- model_name='usertag',
- name='user',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="usertag",
+ name="user",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AlterField(
- model_name='work',
- name='default_edition',
- field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
+ model_name="work",
+ name="default_edition",
+ field=bookwyrm.models.fields.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Edition",
+ ),
),
migrations.AlterField(
- model_name='work',
- name='lccn',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="work",
+ name="lccn",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
]
diff --git a/bookwyrm/migrations/0021_merge_20201212_1737.py b/bookwyrm/migrations/0021_merge_20201212_1737.py
index 4ccf8c8c..c6b48820 100644
--- a/bookwyrm/migrations/0021_merge_20201212_1737.py
+++ b/bookwyrm/migrations/0021_merge_20201212_1737.py
@@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0020_auto_20201208_0213'),
- ('bookwyrm', '0016_auto_20201211_2026'),
+ ("bookwyrm", "0020_auto_20201208_0213"),
+ ("bookwyrm", "0016_auto_20201211_2026"),
]
- operations = [
- ]
+ operations = []
diff --git a/bookwyrm/migrations/0022_auto_20201212_1744.py b/bookwyrm/migrations/0022_auto_20201212_1744.py
index 0a98597f..2651578c 100644
--- a/bookwyrm/migrations/0022_auto_20201212_1744.py
+++ b/bookwyrm/migrations/0022_auto_20201212_1744.py
@@ -5,26 +5,27 @@ from django.db import migrations
def set_author_name(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- authors = app_registry.get_model('bookwyrm', 'Author')
+ authors = app_registry.get_model("bookwyrm", "Author")
for author in authors.objects.using(db_alias):
if not author.name:
- author.name = '%s %s' % (author.first_name, author.last_name)
+ author.name = "%s %s" % (author.first_name, author.last_name)
author.save()
+
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0021_merge_20201212_1737'),
+ ("bookwyrm", "0021_merge_20201212_1737"),
]
operations = [
migrations.RunPython(set_author_name),
migrations.RemoveField(
- model_name='author',
- name='first_name',
+ model_name="author",
+ name="first_name",
),
migrations.RemoveField(
- model_name='author',
- name='last_name',
+ model_name="author",
+ name="last_name",
),
]
diff --git a/bookwyrm/migrations/0023_auto_20201214_0511.py b/bookwyrm/migrations/0023_auto_20201214_0511.py
index e811bded..4b4a0c4a 100644
--- a/bookwyrm/migrations/0023_auto_20201214_0511.py
+++ b/bookwyrm/migrations/0023_auto_20201214_0511.py
@@ -7,13 +7,22 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0022_auto_20201212_1744'),
+ ("bookwyrm", "0022_auto_20201212_1744"),
]
operations = [
migrations.AlterField(
- model_name='status',
- name='privacy',
- field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
+ model_name="status",
+ name="privacy",
+ field=bookwyrm.models.fields.PrivacyField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
),
]
diff --git a/bookwyrm/migrations/0023_merge_20201216_0112.py b/bookwyrm/migrations/0023_merge_20201216_0112.py
index e3af4849..be88546e 100644
--- a/bookwyrm/migrations/0023_merge_20201216_0112.py
+++ b/bookwyrm/migrations/0023_merge_20201216_0112.py
@@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0017_auto_20201212_0059'),
- ('bookwyrm', '0022_auto_20201212_1744'),
+ ("bookwyrm", "0017_auto_20201212_0059"),
+ ("bookwyrm", "0022_auto_20201212_1744"),
]
- operations = [
- ]
+ operations = []
diff --git a/bookwyrm/migrations/0024_merge_20201216_1721.py b/bookwyrm/migrations/0024_merge_20201216_1721.py
index 41f81335..bb944d4e 100644
--- a/bookwyrm/migrations/0024_merge_20201216_1721.py
+++ b/bookwyrm/migrations/0024_merge_20201216_1721.py
@@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0023_auto_20201214_0511'),
- ('bookwyrm', '0023_merge_20201216_0112'),
+ ("bookwyrm", "0023_auto_20201214_0511"),
+ ("bookwyrm", "0023_merge_20201216_0112"),
]
- operations = [
- ]
+ operations = []
diff --git a/bookwyrm/migrations/0025_auto_20201217_0046.py b/bookwyrm/migrations/0025_auto_20201217_0046.py
index a3ffe8c1..82e1f503 100644
--- a/bookwyrm/migrations/0025_auto_20201217_0046.py
+++ b/bookwyrm/migrations/0025_auto_20201217_0046.py
@@ -7,33 +7,33 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0024_merge_20201216_1721'),
+ ("bookwyrm", "0024_merge_20201216_1721"),
]
operations = [
migrations.AlterField(
- model_name='author',
- name='bio',
+ model_name="author",
+ name="bio",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
- model_name='book',
- name='description',
+ model_name="book",
+ name="description",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
- model_name='quotation',
- name='quote',
+ model_name="quotation",
+ name="quote",
field=bookwyrm.models.fields.HtmlField(),
),
migrations.AlterField(
- model_name='status',
- name='content',
+ model_name="status",
+ name="content",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
- model_name='user',
- name='summary',
- field=bookwyrm.models.fields.HtmlField(default=''),
+ model_name="user",
+ name="summary",
+ field=bookwyrm.models.fields.HtmlField(default=""),
),
]
diff --git a/bookwyrm/migrations/0026_status_content_warning.py b/bookwyrm/migrations/0026_status_content_warning.py
index f4e494db..5212e83a 100644
--- a/bookwyrm/migrations/0026_status_content_warning.py
+++ b/bookwyrm/migrations/0026_status_content_warning.py
@@ -7,13 +7,15 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0025_auto_20201217_0046'),
+ ("bookwyrm", "0025_auto_20201217_0046"),
]
operations = [
migrations.AddField(
- model_name='status',
- name='content_warning',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=500, null=True),
+ model_name="status",
+ name="content_warning",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=500, null=True
+ ),
),
]
diff --git a/bookwyrm/migrations/0027_auto_20201220_2007.py b/bookwyrm/migrations/0027_auto_20201220_2007.py
index a3ad4dda..5eec5139 100644
--- a/bookwyrm/migrations/0027_auto_20201220_2007.py
+++ b/bookwyrm/migrations/0027_auto_20201220_2007.py
@@ -7,18 +7,20 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0026_status_content_warning'),
+ ("bookwyrm", "0026_status_content_warning"),
]
operations = [
migrations.AlterField(
- model_name='user',
- name='name',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True),
+ model_name="user",
+ name="name",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=100, null=True
+ ),
),
migrations.AlterField(
- model_name='user',
- name='summary',
+ model_name="user",
+ name="summary",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
]
diff --git a/bookwyrm/migrations/0028_remove_book_author_text.py b/bookwyrm/migrations/0028_remove_book_author_text.py
index 8743c910..1f91d1c1 100644
--- a/bookwyrm/migrations/0028_remove_book_author_text.py
+++ b/bookwyrm/migrations/0028_remove_book_author_text.py
@@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0027_auto_20201220_2007'),
+ ("bookwyrm", "0027_auto_20201220_2007"),
]
operations = [
migrations.RemoveField(
- model_name='book',
- name='author_text',
+ model_name="book",
+ name="author_text",
),
]
diff --git a/bookwyrm/migrations/0029_auto_20201221_2014.py b/bookwyrm/migrations/0029_auto_20201221_2014.py
index ebf27a74..7a6b7180 100644
--- a/bookwyrm/migrations/0029_auto_20201221_2014.py
+++ b/bookwyrm/migrations/0029_auto_20201221_2014.py
@@ -9,53 +9,65 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0028_remove_book_author_text'),
+ ("bookwyrm", "0028_remove_book_author_text"),
]
operations = [
migrations.RemoveField(
- model_name='author',
- name='last_sync_date',
+ model_name="author",
+ name="last_sync_date",
),
migrations.RemoveField(
- model_name='author',
- name='sync',
+ model_name="author",
+ name="sync",
),
migrations.RemoveField(
- model_name='book',
- name='last_sync_date',
+ model_name="book",
+ name="last_sync_date",
),
migrations.RemoveField(
- model_name='book',
- name='sync',
+ model_name="book",
+ name="sync",
),
migrations.RemoveField(
- model_name='book',
- name='sync_cover',
+ model_name="book",
+ name="sync_cover",
),
migrations.AddField(
- model_name='author',
- name='goodreads_key',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="author",
+ name="goodreads_key",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AddField(
- model_name='author',
- name='last_edited_by',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="author",
+ name="last_edited_by",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AddField(
- model_name='author',
- name='librarything_key',
- field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
+ model_name="author",
+ name="librarything_key",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
),
migrations.AddField(
- model_name='book',
- name='last_edited_by',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="book",
+ name="last_edited_by",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='author',
- name='origin_id',
+ model_name="author",
+ name="origin_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]
diff --git a/bookwyrm/migrations/0030_auto_20201224_1939.py b/bookwyrm/migrations/0030_auto_20201224_1939.py
index 6de5d37f..beee20c4 100644
--- a/bookwyrm/migrations/0030_auto_20201224_1939.py
+++ b/bookwyrm/migrations/0030_auto_20201224_1939.py
@@ -7,13 +7,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0029_auto_20201221_2014'),
+ ("bookwyrm", "0029_auto_20201221_2014"),
]
operations = [
migrations.AlterField(
- model_name='user',
- name='localname',
- field=models.CharField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_localname]),
+ model_name="user",
+ name="localname",
+ field=models.CharField(
+ max_length=255,
+ null=True,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_localname],
+ ),
),
]
diff --git a/bookwyrm/migrations/0031_auto_20210104_2040.py b/bookwyrm/migrations/0031_auto_20210104_2040.py
index 604392d4..c6418fc9 100644
--- a/bookwyrm/migrations/0031_auto_20210104_2040.py
+++ b/bookwyrm/migrations/0031_auto_20210104_2040.py
@@ -6,23 +6,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0030_auto_20201224_1939'),
+ ("bookwyrm", "0030_auto_20201224_1939"),
]
operations = [
migrations.AddField(
- model_name='sitesettings',
- name='favicon',
- field=models.ImageField(blank=True, null=True, upload_to='logos/'),
+ model_name="sitesettings",
+ name="favicon",
+ field=models.ImageField(blank=True, null=True, upload_to="logos/"),
),
migrations.AddField(
- model_name='sitesettings',
- name='logo',
- field=models.ImageField(blank=True, null=True, upload_to='logos/'),
+ model_name="sitesettings",
+ name="logo",
+ field=models.ImageField(blank=True, null=True, upload_to="logos/"),
),
migrations.AddField(
- model_name='sitesettings',
- name='logo_small',
- field=models.ImageField(blank=True, null=True, upload_to='logos/'),
+ model_name="sitesettings",
+ name="logo_small",
+ field=models.ImageField(blank=True, null=True, upload_to="logos/"),
),
]
diff --git a/bookwyrm/migrations/0032_auto_20210104_2055.py b/bookwyrm/migrations/0032_auto_20210104_2055.py
index 692cd581..8b8012da 100644
--- a/bookwyrm/migrations/0032_auto_20210104_2055.py
+++ b/bookwyrm/migrations/0032_auto_20210104_2055.py
@@ -6,18 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0031_auto_20210104_2040'),
+ ("bookwyrm", "0031_auto_20210104_2040"),
]
operations = [
migrations.AddField(
- model_name='sitesettings',
- name='instance_tagline',
- field=models.CharField(default='Social Reading and Reviewing', max_length=150),
+ model_name="sitesettings",
+ name="instance_tagline",
+ field=models.CharField(
+ default="Social Reading and Reviewing", max_length=150
+ ),
),
migrations.AddField(
- model_name='sitesettings',
- name='registration_closed_text',
- field=models.TextField(default='Contact an administrator to get an invite'),
+ model_name="sitesettings",
+ name="registration_closed_text",
+ field=models.TextField(default="Contact an administrator to get an invite"),
),
]
diff --git a/bookwyrm/migrations/0033_siteinvite_created_date.py b/bookwyrm/migrations/0033_siteinvite_created_date.py
index 9a3f9896..36d489eb 100644
--- a/bookwyrm/migrations/0033_siteinvite_created_date.py
+++ b/bookwyrm/migrations/0033_siteinvite_created_date.py
@@ -7,14 +7,16 @@ import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0032_auto_20210104_2055'),
+ ("bookwyrm", "0032_auto_20210104_2055"),
]
operations = [
migrations.AddField(
- model_name='siteinvite',
- name='created_date',
- field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
+ model_name="siteinvite",
+ name="created_date",
+ field=models.DateTimeField(
+ auto_now_add=True, default=django.utils.timezone.now
+ ),
preserve_default=False,
),
]
diff --git a/bookwyrm/migrations/0034_importjob_complete.py b/bookwyrm/migrations/0034_importjob_complete.py
index 14170607..6593df9f 100644
--- a/bookwyrm/migrations/0034_importjob_complete.py
+++ b/bookwyrm/migrations/0034_importjob_complete.py
@@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0033_siteinvite_created_date'),
+ ("bookwyrm", "0033_siteinvite_created_date"),
]
operations = [
migrations.AddField(
- model_name='importjob',
- name='complete',
+ model_name="importjob",
+ name="complete",
field=models.BooleanField(default=False),
),
]
diff --git a/bookwyrm/migrations/0035_edition_edition_rank.py b/bookwyrm/migrations/0035_edition_edition_rank.py
index 1a75a097..7465c31b 100644
--- a/bookwyrm/migrations/0035_edition_edition_rank.py
+++ b/bookwyrm/migrations/0035_edition_edition_rank.py
@@ -6,20 +6,21 @@ from django.db import migrations
def set_rank(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- books = app_registry.get_model('bookwyrm', 'Edition')
+ books = app_registry.get_model("bookwyrm", "Edition")
for book in books.objects.using(db_alias):
book.save()
+
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0034_importjob_complete'),
+ ("bookwyrm", "0034_importjob_complete"),
]
operations = [
migrations.AddField(
- model_name='edition',
- name='edition_rank',
+ model_name="edition",
+ name="edition_rank",
field=bookwyrm.models.fields.IntegerField(default=0),
),
migrations.RunPython(set_rank),
diff --git a/bookwyrm/migrations/0036_annualgoal.py b/bookwyrm/migrations/0036_annualgoal.py
index fb12833e..fd08fb24 100644
--- a/bookwyrm/migrations/0036_annualgoal.py
+++ b/bookwyrm/migrations/0036_annualgoal.py
@@ -9,24 +9,57 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0035_edition_edition_rank'),
+ ("bookwyrm", "0035_edition_edition_rank"),
]
operations = [
migrations.CreateModel(
- name='AnnualGoal',
+ name="AnnualGoal",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
- ('goal', models.IntegerField()),
- ('year', models.IntegerField(default=2021)),
- ('privacy', models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
- ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("goal", models.IntegerField()),
+ ("year", models.IntegerField(default=2021)),
+ (
+ "privacy",
+ models.CharField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
],
options={
- 'unique_together': {('user', 'year')},
+ "unique_together": {("user", "year")},
},
),
]
diff --git a/bookwyrm/migrations/0037_auto_20210118_1954.py b/bookwyrm/migrations/0037_auto_20210118_1954.py
index 97ba8808..a0c27d45 100644
--- a/bookwyrm/migrations/0037_auto_20210118_1954.py
+++ b/bookwyrm/migrations/0037_auto_20210118_1954.py
@@ -2,36 +2,39 @@
from django.db import migrations, models
+
def empty_to_null(apps, schema_editor):
User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email="").update(email=None)
+
def null_to_empty(apps, schema_editor):
User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email=None).update(email="")
+
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0036_annualgoal'),
+ ("bookwyrm", "0036_annualgoal"),
]
operations = [
migrations.AlterModelOptions(
- name='shelfbook',
- options={'ordering': ('-created_date',)},
+ name="shelfbook",
+ options={"ordering": ("-created_date",)},
),
migrations.AlterField(
- model_name='user',
- name='email',
+ model_name="user",
+ name="email",
field=models.EmailField(max_length=254, null=True),
),
migrations.RunPython(empty_to_null, null_to_empty),
migrations.AlterField(
- model_name='user',
- name='email',
+ model_name="user",
+ name="email",
field=models.EmailField(max_length=254, null=True, unique=True),
),
]
diff --git a/bookwyrm/migrations/0038_auto_20210119_1534.py b/bookwyrm/migrations/0038_auto_20210119_1534.py
index ac7a0d68..14fd1ff2 100644
--- a/bookwyrm/migrations/0038_auto_20210119_1534.py
+++ b/bookwyrm/migrations/0038_auto_20210119_1534.py
@@ -7,13 +7,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0037_auto_20210118_1954'),
+ ("bookwyrm", "0037_auto_20210118_1954"),
]
operations = [
migrations.AlterField(
- model_name='annualgoal',
- name='goal',
- field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1)]),
+ model_name="annualgoal",
+ name="goal",
+ field=models.IntegerField(
+ validators=[django.core.validators.MinValueValidator(1)]
+ ),
),
]
diff --git a/bookwyrm/migrations/0039_merge_20210120_0753.py b/bookwyrm/migrations/0039_merge_20210120_0753.py
index 1af40ee9..e698d8ea 100644
--- a/bookwyrm/migrations/0039_merge_20210120_0753.py
+++ b/bookwyrm/migrations/0039_merge_20210120_0753.py
@@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0038_auto_20210119_1534'),
- ('bookwyrm', '0015_auto_20201128_0734'),
+ ("bookwyrm", "0038_auto_20210119_1534"),
+ ("bookwyrm", "0015_auto_20201128_0734"),
]
- operations = [
- ]
+ operations = []
diff --git a/bookwyrm/migrations/0040_auto_20210122_0057.py b/bookwyrm/migrations/0040_auto_20210122_0057.py
index 8e528a89..0641f527 100644
--- a/bookwyrm/migrations/0040_auto_20210122_0057.py
+++ b/bookwyrm/migrations/0040_auto_20210122_0057.py
@@ -9,28 +9,40 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0039_merge_20210120_0753'),
+ ("bookwyrm", "0039_merge_20210120_0753"),
]
operations = [
migrations.AlterField(
- model_name='progressupdate',
- name='progress',
- field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]),
+ model_name="progressupdate",
+ name="progress",
+ field=models.IntegerField(
+ validators=[django.core.validators.MinValueValidator(0)]
+ ),
),
migrations.AlterField(
- model_name='progressupdate',
- name='readthrough',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ReadThrough'),
+ model_name="progressupdate",
+ name="readthrough",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.ReadThrough"
+ ),
),
migrations.AlterField(
- model_name='progressupdate',
- name='remote_id',
- field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
+ model_name="progressupdate",
+ name="remote_id",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
),
migrations.AlterField(
- model_name='readthrough',
- name='progress',
- field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)]),
+ model_name="readthrough",
+ name="progress",
+ field=models.IntegerField(
+ blank=True,
+ null=True,
+ validators=[django.core.validators.MinValueValidator(0)],
+ ),
),
]
diff --git a/bookwyrm/migrations/0041_auto_20210131_1614.py b/bookwyrm/migrations/0041_auto_20210131_1614.py
index 6fcf406b..01085dea 100644
--- a/bookwyrm/migrations/0041_auto_20210131_1614.py
+++ b/bookwyrm/migrations/0041_auto_20210131_1614.py
@@ -10,56 +10,141 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0040_auto_20210122_0057'),
+ ("bookwyrm", "0040_auto_20210122_0057"),
]
operations = [
migrations.CreateModel(
- name='List',
+ name="List",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
- ('name', bookwyrm.models.fields.CharField(max_length=100)),
- ('description', bookwyrm.models.fields.TextField(blank=True, null=True)),
- ('privacy', bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
- ('curation', bookwyrm.models.fields.CharField(choices=[('closed', 'Closed'), ('open', 'Open'), ('curated', 'Curated')], default='closed', max_length=255)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("name", bookwyrm.models.fields.CharField(max_length=100)),
+ (
+ "description",
+ bookwyrm.models.fields.TextField(blank=True, null=True),
+ ),
+ (
+ "privacy",
+ bookwyrm.models.fields.CharField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
+ ),
+ (
+ "curation",
+ bookwyrm.models.fields.CharField(
+ choices=[
+ ("closed", "Closed"),
+ ("open", "Open"),
+ ("curated", "Curated"),
+ ],
+ default="closed",
+ max_length=255,
+ ),
+ ),
],
options={
- 'abstract': False,
+ "abstract": False,
},
- bases=(bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, models.Model),
+ bases=(
+ bookwyrm.models.activitypub_mixin.OrderedCollectionMixin,
+ models.Model,
+ ),
),
migrations.CreateModel(
- name='ListItem',
+ name="ListItem",
fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
- ('created_date', models.DateTimeField(auto_now_add=True)),
- ('updated_date', models.DateTimeField(auto_now=True)),
- ('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
- ('notes', bookwyrm.models.fields.TextField(blank=True, null=True)),
- ('approved', models.BooleanField(default=True)),
- ('order', bookwyrm.models.fields.IntegerField(blank=True, null=True)),
- ('added_by', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
- ('book', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
- ('book_list', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.List')),
- ('endorsement', models.ManyToManyField(related_name='endorsers', to=settings.AUTH_USER_MODEL)),
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("notes", bookwyrm.models.fields.TextField(blank=True, null=True)),
+ ("approved", models.BooleanField(default=True)),
+ ("order", bookwyrm.models.fields.IntegerField(blank=True, null=True)),
+ (
+ "added_by",
+ bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "book",
+ bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Edition",
+ ),
+ ),
+ (
+ "book_list",
+ bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.List"
+ ),
+ ),
+ (
+ "endorsement",
+ models.ManyToManyField(
+ related_name="endorsers", to=settings.AUTH_USER_MODEL
+ ),
+ ),
],
options={
- 'ordering': ('-created_date',),
- 'unique_together': {('book', 'book_list')},
+ "ordering": ("-created_date",),
+ "unique_together": {("book", "book_list")},
},
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.AddField(
- model_name='list',
- name='books',
- field=models.ManyToManyField(through='bookwyrm.ListItem', to='bookwyrm.Edition'),
+ model_name="list",
+ name="books",
+ field=models.ManyToManyField(
+ through="bookwyrm.ListItem", to="bookwyrm.Edition"
+ ),
),
migrations.AddField(
- model_name='list',
- name='user',
- field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="list",
+ name="user",
+ field=bookwyrm.models.fields.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
+ ),
),
]
diff --git a/bookwyrm/migrations/0042_auto_20210201_2108.py b/bookwyrm/migrations/0042_auto_20210201_2108.py
index 95a144de..ee7201c1 100644
--- a/bookwyrm/migrations/0042_auto_20210201_2108.py
+++ b/bookwyrm/migrations/0042_auto_20210201_2108.py
@@ -7,22 +7,40 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0041_auto_20210131_1614'),
+ ("bookwyrm", "0041_auto_20210131_1614"),
]
operations = [
migrations.AlterModelOptions(
- name='list',
- options={'ordering': ('-updated_date',)},
+ name="list",
+ options={"ordering": ("-updated_date",)},
),
migrations.AlterField(
- model_name='list',
- name='privacy',
- field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
+ model_name="list",
+ name="privacy",
+ field=bookwyrm.models.fields.PrivacyField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
),
migrations.AlterField(
- model_name='shelf',
- name='privacy',
- field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
+ model_name="shelf",
+ name="privacy",
+ field=bookwyrm.models.fields.PrivacyField(
+ choices=[
+ ("public", "Public"),
+ ("unlisted", "Unlisted"),
+ ("followers", "Followers"),
+ ("direct", "Direct"),
+ ],
+ default="public",
+ max_length=255,
+ ),
),
]
diff --git a/bookwyrm/migrations/0043_auto_20210204_2223.py b/bookwyrm/migrations/0043_auto_20210204_2223.py
index b9c328ea..2e8318c5 100644
--- a/bookwyrm/migrations/0043_auto_20210204_2223.py
+++ b/bookwyrm/migrations/0043_auto_20210204_2223.py
@@ -6,18 +6,18 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0042_auto_20210201_2108'),
+ ("bookwyrm", "0042_auto_20210201_2108"),
]
operations = [
migrations.RenameField(
- model_name='listitem',
- old_name='added_by',
- new_name='user',
+ model_name="listitem",
+ old_name="added_by",
+ new_name="user",
),
migrations.RenameField(
- model_name='shelfbook',
- old_name='added_by',
- new_name='user',
+ model_name="shelfbook",
+ old_name="added_by",
+ new_name="user",
),
]
diff --git a/bookwyrm/migrations/0044_auto_20210207_1924.py b/bookwyrm/migrations/0044_auto_20210207_1924.py
index 7289c73d..897e8e02 100644
--- a/bookwyrm/migrations/0044_auto_20210207_1924.py
+++ b/bookwyrm/migrations/0044_auto_20210207_1924.py
@@ -5,9 +5,10 @@ from django.conf import settings
from django.db import migrations
import django.db.models.deletion
+
def set_user(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
- shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook')
+ shelfbook = app_registry.get_model("bookwyrm", "ShelfBook")
for item in shelfbook.objects.using(db_alias).filter(user__isnull=True):
item.user = item.shelf.user
try:
@@ -19,15 +20,19 @@ def set_user(app_registry, schema_editor):
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0043_auto_20210204_2223'),
+ ("bookwyrm", "0043_auto_20210204_2223"),
]
operations = [
migrations.RunPython(set_user, lambda x, y: None),
migrations.AlterField(
- model_name='shelfbook',
- name='user',
- field=bookwyrm.models.fields.ForeignKey(default=2, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
+ model_name="shelfbook",
+ name="user",
+ field=bookwyrm.models.fields.ForeignKey(
+ default=2,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
preserve_default=False,
),
]
diff --git a/bookwyrm/migrations/0045_auto_20210210_2114.py b/bookwyrm/migrations/0045_auto_20210210_2114.py
index 87b9a318..22f33cf4 100644
--- a/bookwyrm/migrations/0045_auto_20210210_2114.py
+++ b/bookwyrm/migrations/0045_auto_20210210_2114.py
@@ -8,51 +8,102 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- ('bookwyrm', '0044_auto_20210207_1924'),
+ ("bookwyrm", "0044_auto_20210207_1924"),
]
operations = [
migrations.RemoveConstraint(
- model_name='notification',
- name='notification_type_valid',
+ model_name="notification",
+ name="notification_type_valid",
),
migrations.AddField(
- model_name='notification',
- name='related_list_item',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ListItem'),
+ model_name="notification",
+ name="related_list_item",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.ListItem",
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='notification_type',
- field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import'), ('ADD', 'Add')], max_length=255),
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("BOOST", "Boost"),
+ ("IMPORT", "Import"),
+ ("ADD", "Add"),
+ ],
+ max_length=255,
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='related_book',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Edition'),
+ model_name="notification",
+ name="related_book",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.Edition",
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='related_import',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ImportJob'),
+ model_name="notification",
+ name="related_import",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.ImportJob",
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='related_status',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Status'),
+ model_name="notification",
+ name="related_status",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.Status",
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='related_user',
- field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_user', to=settings.AUTH_USER_MODEL),
+ model_name="notification",
+ name="related_user",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="related_user",
+ to=settings.AUTH_USER_MODEL,
+ ),
),
migrations.AlterField(
- model_name='notification',
- name='user',
- field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
+ model_name="notification",
+ name="user",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
+ ),
),
migrations.AddConstraint(
- model_name='notification',
- constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT', 'ADD']), name='notification_type_valid'),
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ notification_type__in=[
+ "FAVORITE",
+ "REPLY",
+ "MENTION",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ "BOOST",
+ "IMPORT",
+ "ADD",
+ ]
+ ),
+ name="notification_type_valid",
+ ),
),
]
diff --git a/bookwyrm/migrations/0046_reviewrating.py b/bookwyrm/migrations/0046_reviewrating.py
new file mode 100644
index 00000000..26f6f36a
--- /dev/null
+++ b/bookwyrm/migrations/0046_reviewrating.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.0.7 on 2021-02-25 18:36
+
+from django.db import migrations, models
+from django.db import connection
+from django.db.models import Q
+import django.db.models.deletion
+from psycopg2.extras import execute_values
+
+
+def convert_review_rating(app_registry, schema_editor):
+ """take rating type Reviews and convert them to ReviewRatings"""
+ db_alias = schema_editor.connection.alias
+
+ reviews = (
+ app_registry.get_model("bookwyrm", "Review")
+ .objects.using(db_alias)
+ .filter(Q(content__isnull=True) | Q(content=""))
+ )
+
+ with connection.cursor() as cursor:
+ values = [(r.id,) for r in reviews]
+ execute_values(
+ cursor,
+ """
+INSERT INTO bookwyrm_reviewrating(review_ptr_id)
+VALUES %s""",
+ values,
+ )
+
+
+def unconvert_review_rating(app_registry, schema_editor):
+ """undo the conversion from ratings back to reviews"""
+ # All we need to do to revert this is drop the table, which Django will do
+ # on its own, as long as we have a valid reverse function. So, this is a
+ # no-op function so Django will do its thing
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0045_auto_20210210_2114"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="ReviewRating",
+ fields=[
+ (
+ "review_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="bookwyrm.Review",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("bookwyrm.review",),
+ ),
+ migrations.RunPython(convert_review_rating, unconvert_review_rating),
+ ]
diff --git a/bookwyrm/migrations/0046_sitesettings_privacy_policy.py b/bookwyrm/migrations/0046_sitesettings_privacy_policy.py
new file mode 100644
index 00000000..f9193764
--- /dev/null
+++ b/bookwyrm/migrations/0046_sitesettings_privacy_policy.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.7 on 2021-02-27 19:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0045_auto_20210210_2114"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="privacy_policy",
+ field=models.TextField(default="Add a privacy policy here."),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0047_connector_isbn_search_url.py b/bookwyrm/migrations/0047_connector_isbn_search_url.py
new file mode 100644
index 00000000..2ca802c5
--- /dev/null
+++ b/bookwyrm/migrations/0047_connector_isbn_search_url.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.7 on 2021-02-28 16:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0046_sitesettings_privacy_policy"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="connector",
+ name="isbn_search_url",
+ field=models.CharField(blank=True, max_length=255, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0047_merge_20210228_1839.py b/bookwyrm/migrations/0047_merge_20210228_1839.py
new file mode 100644
index 00000000..4be39e56
--- /dev/null
+++ b/bookwyrm/migrations/0047_merge_20210228_1839.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.0.7 on 2021-02-28 18:39
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0046_reviewrating"),
+ ("bookwyrm", "0046_sitesettings_privacy_policy"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0048_merge_20210308_1754.py b/bookwyrm/migrations/0048_merge_20210308_1754.py
new file mode 100644
index 00000000..47fa9e77
--- /dev/null
+++ b/bookwyrm/migrations/0048_merge_20210308_1754.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.0.7 on 2021-03-08 17:54
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0047_connector_isbn_search_url"),
+ ("bookwyrm", "0047_merge_20210228_1839"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0049_auto_20210309_0156.py b/bookwyrm/migrations/0049_auto_20210309_0156.py
new file mode 100644
index 00000000..ae9d77a8
--- /dev/null
+++ b/bookwyrm/migrations/0049_auto_20210309_0156.py
@@ -0,0 +1,113 @@
+# Generated by Django 3.0.7 on 2021-03-09 01:56
+
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.db.models.expressions
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0048_merge_20210308_1754"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Report",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("note", models.TextField(blank=True, null=True)),
+ ("resolved", models.BooleanField(default=False)),
+ (
+ "reporter",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="reporter",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "statuses",
+ models.ManyToManyField(blank=True, null=True, to="bookwyrm.Status"),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="ReportComment",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("note", models.TextField()),
+ (
+ "report",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to="bookwyrm.Report",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.AddConstraint(
+ model_name="report",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ _negated=True, reporter=django.db.models.expressions.F("user")
+ ),
+ name="self_report",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0050_auto_20210313_0030.py b/bookwyrm/migrations/0050_auto_20210313_0030.py
new file mode 100644
index 00000000..8c81c452
--- /dev/null
+++ b/bookwyrm/migrations/0050_auto_20210313_0030.py
@@ -0,0 +1,26 @@
+# Generated by Django 3.0.7 on 2021-03-13 00:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0049_auto_20210309_0156"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="report",
+ options={"ordering": ("-created_date",)},
+ ),
+ migrations.AlterModelOptions(
+ name="reportcomment",
+ options={"ordering": ("-created_date",)},
+ ),
+ migrations.AlterField(
+ model_name="report",
+ name="statuses",
+ field=models.ManyToManyField(blank=True, to="bookwyrm.Status"),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0051_auto_20210316_1950.py b/bookwyrm/migrations/0051_auto_20210316_1950.py
new file mode 100644
index 00000000..3caecbbe
--- /dev/null
+++ b/bookwyrm/migrations/0051_auto_20210316_1950.py
@@ -0,0 +1,66 @@
+# Generated by Django 3.0.7 on 2021-03-16 19:50
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0050_auto_20210313_0030"),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="notification",
+ name="notification_type_valid",
+ ),
+ migrations.AddField(
+ model_name="notification",
+ name="related_report",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ to="bookwyrm.Report",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="notification",
+ name="notification_type",
+ field=models.CharField(
+ choices=[
+ ("FAVORITE", "Favorite"),
+ ("REPLY", "Reply"),
+ ("MENTION", "Mention"),
+ ("TAG", "Tag"),
+ ("FOLLOW", "Follow"),
+ ("FOLLOW_REQUEST", "Follow Request"),
+ ("BOOST", "Boost"),
+ ("IMPORT", "Import"),
+ ("ADD", "Add"),
+ ("REPORT", "Report"),
+ ],
+ max_length=255,
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="notification",
+ constraint=models.CheckConstraint(
+ check=models.Q(
+ notification_type__in=[
+ "FAVORITE",
+ "REPLY",
+ "MENTION",
+ "TAG",
+ "FOLLOW",
+ "FOLLOW_REQUEST",
+ "BOOST",
+ "IMPORT",
+ "ADD",
+ "REPORT",
+ ]
+ ),
+ name="notification_type_valid",
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0052_user_show_goal.py b/bookwyrm/migrations/0052_user_show_goal.py
new file mode 100644
index 00000000..3b72ee7a
--- /dev/null
+++ b/bookwyrm/migrations/0052_user_show_goal.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.0.7 on 2021-03-18 15:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0051_auto_20210316_1950"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="show_goal",
+ field=models.BooleanField(default=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0053_auto_20210319_1913.py b/bookwyrm/migrations/0053_auto_20210319_1913.py
new file mode 100644
index 00000000..023319b3
--- /dev/null
+++ b/bookwyrm/migrations/0053_auto_20210319_1913.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.1.6 on 2021-03-19 19:13
+
+import bookwyrm.models.fields
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0052_user_show_goal"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="review",
+ name="rating",
+ field=bookwyrm.models.fields.DecimalField(
+ blank=True,
+ decimal_places=2,
+ default=None,
+ max_digits=3,
+ null=True,
+ validators=[
+ django.core.validators.MinValueValidator(1),
+ django.core.validators.MaxValueValidator(5),
+ ],
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0054_auto_20210319_1942.py b/bookwyrm/migrations/0054_auto_20210319_1942.py
new file mode 100644
index 00000000..5d5865b6
--- /dev/null
+++ b/bookwyrm/migrations/0054_auto_20210319_1942.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.1.6 on 2021-03-19 19:42
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0053_auto_20210319_1913"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="importitem",
+ name="data",
+ field=models.JSONField(),
+ ),
+ migrations.AlterField(
+ model_name="user",
+ name="first_name",
+ field=models.CharField(
+ blank=True, max_length=150, verbose_name="first name"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0055_auto_20210321_0101.py b/bookwyrm/migrations/0055_auto_20210321_0101.py
new file mode 100644
index 00000000..dea219c4
--- /dev/null
+++ b/bookwyrm/migrations/0055_auto_20210321_0101.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.1.6 on 2021-03-21 01:01
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0054_auto_20210319_1942"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="comment",
+ name="progress",
+ field=models.IntegerField(
+ blank=True,
+ null=True,
+ validators=[django.core.validators.MinValueValidator(0)],
+ ),
+ ),
+ migrations.AddField(
+ model_name="comment",
+ name="progress_mode",
+ field=models.CharField(
+ blank=True,
+ choices=[("PG", "page"), ("PCT", "percent")],
+ default="PG",
+ max_length=3,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0056_auto_20210321_0303.py b/bookwyrm/migrations/0056_auto_20210321_0303.py
new file mode 100644
index 00000000..aa475e03
--- /dev/null
+++ b/bookwyrm/migrations/0056_auto_20210321_0303.py
@@ -0,0 +1,59 @@
+# Generated by Django 3.1.6 on 2021-03-21 03:03
+
+import bookwyrm.models.fields
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0055_auto_20210321_0101"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="allow_invite_requests",
+ field=models.BooleanField(default=True),
+ ),
+ migrations.CreateModel(
+ name="InviteRequest",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("email", models.EmailField(max_length=255, unique=True)),
+ ("invite_sent", models.BooleanField(default=False)),
+ ("ignored", models.BooleanField(default=False)),
+ (
+ "invite",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="bookwyrm.siteinvite",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/bookwyrm/migrations/0057_user_discoverable.py b/bookwyrm/migrations/0057_user_discoverable.py
new file mode 100644
index 00000000..c49592bf
--- /dev/null
+++ b/bookwyrm/migrations/0057_user_discoverable.py
@@ -0,0 +1,19 @@
+# Generated by Django 3.1.6 on 2021-03-21 21:44
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0056_auto_20210321_0303"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="discoverable",
+ field=bookwyrm.models.fields.BooleanField(default=False),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0058_auto_20210324_1536.py b/bookwyrm/migrations/0058_auto_20210324_1536.py
new file mode 100644
index 00000000..0fa8c90b
--- /dev/null
+++ b/bookwyrm/migrations/0058_auto_20210324_1536.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.1.6 on 2021-03-24 15:36
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0057_user_discoverable"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="status",
+ options={"ordering": ("-published_date",)},
+ ),
+ ]
diff --git a/bookwyrm/migrations/0059_user_preferred_timezone.py b/bookwyrm/migrations/0059_user_preferred_timezone.py
new file mode 100644
index 00000000..122ec121
--- /dev/null
+++ b/bookwyrm/migrations/0059_user_preferred_timezone.py
@@ -0,0 +1,628 @@
+# Generated by Django 3.1.6 on 2021-03-28 21:02
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0058_auto_20210324_1536"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="preferred_timezone",
+ field=models.CharField(
+ choices=[
+ ("Africa/Abidjan", "Africa/Abidjan"),
+ ("Africa/Accra", "Africa/Accra"),
+ ("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
+ ("Africa/Algiers", "Africa/Algiers"),
+ ("Africa/Asmara", "Africa/Asmara"),
+ ("Africa/Asmera", "Africa/Asmera"),
+ ("Africa/Bamako", "Africa/Bamako"),
+ ("Africa/Bangui", "Africa/Bangui"),
+ ("Africa/Banjul", "Africa/Banjul"),
+ ("Africa/Bissau", "Africa/Bissau"),
+ ("Africa/Blantyre", "Africa/Blantyre"),
+ ("Africa/Brazzaville", "Africa/Brazzaville"),
+ ("Africa/Bujumbura", "Africa/Bujumbura"),
+ ("Africa/Cairo", "Africa/Cairo"),
+ ("Africa/Casablanca", "Africa/Casablanca"),
+ ("Africa/Ceuta", "Africa/Ceuta"),
+ ("Africa/Conakry", "Africa/Conakry"),
+ ("Africa/Dakar", "Africa/Dakar"),
+ ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
+ ("Africa/Djibouti", "Africa/Djibouti"),
+ ("Africa/Douala", "Africa/Douala"),
+ ("Africa/El_Aaiun", "Africa/El_Aaiun"),
+ ("Africa/Freetown", "Africa/Freetown"),
+ ("Africa/Gaborone", "Africa/Gaborone"),
+ ("Africa/Harare", "Africa/Harare"),
+ ("Africa/Johannesburg", "Africa/Johannesburg"),
+ ("Africa/Juba", "Africa/Juba"),
+ ("Africa/Kampala", "Africa/Kampala"),
+ ("Africa/Khartoum", "Africa/Khartoum"),
+ ("Africa/Kigali", "Africa/Kigali"),
+ ("Africa/Kinshasa", "Africa/Kinshasa"),
+ ("Africa/Lagos", "Africa/Lagos"),
+ ("Africa/Libreville", "Africa/Libreville"),
+ ("Africa/Lome", "Africa/Lome"),
+ ("Africa/Luanda", "Africa/Luanda"),
+ ("Africa/Lubumbashi", "Africa/Lubumbashi"),
+ ("Africa/Lusaka", "Africa/Lusaka"),
+ ("Africa/Malabo", "Africa/Malabo"),
+ ("Africa/Maputo", "Africa/Maputo"),
+ ("Africa/Maseru", "Africa/Maseru"),
+ ("Africa/Mbabane", "Africa/Mbabane"),
+ ("Africa/Mogadishu", "Africa/Mogadishu"),
+ ("Africa/Monrovia", "Africa/Monrovia"),
+ ("Africa/Nairobi", "Africa/Nairobi"),
+ ("Africa/Ndjamena", "Africa/Ndjamena"),
+ ("Africa/Niamey", "Africa/Niamey"),
+ ("Africa/Nouakchott", "Africa/Nouakchott"),
+ ("Africa/Ouagadougou", "Africa/Ouagadougou"),
+ ("Africa/Porto-Novo", "Africa/Porto-Novo"),
+ ("Africa/Sao_Tome", "Africa/Sao_Tome"),
+ ("Africa/Timbuktu", "Africa/Timbuktu"),
+ ("Africa/Tripoli", "Africa/Tripoli"),
+ ("Africa/Tunis", "Africa/Tunis"),
+ ("Africa/Windhoek", "Africa/Windhoek"),
+ ("America/Adak", "America/Adak"),
+ ("America/Anchorage", "America/Anchorage"),
+ ("America/Anguilla", "America/Anguilla"),
+ ("America/Antigua", "America/Antigua"),
+ ("America/Araguaina", "America/Araguaina"),
+ (
+ "America/Argentina/Buenos_Aires",
+ "America/Argentina/Buenos_Aires",
+ ),
+ ("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
+ (
+ "America/Argentina/ComodRivadavia",
+ "America/Argentina/ComodRivadavia",
+ ),
+ ("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
+ ("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
+ ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
+ ("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
+ (
+ "America/Argentina/Rio_Gallegos",
+ "America/Argentina/Rio_Gallegos",
+ ),
+ ("America/Argentina/Salta", "America/Argentina/Salta"),
+ ("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
+ ("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
+ ("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
+ ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
+ ("America/Aruba", "America/Aruba"),
+ ("America/Asuncion", "America/Asuncion"),
+ ("America/Atikokan", "America/Atikokan"),
+ ("America/Atka", "America/Atka"),
+ ("America/Bahia", "America/Bahia"),
+ ("America/Bahia_Banderas", "America/Bahia_Banderas"),
+ ("America/Barbados", "America/Barbados"),
+ ("America/Belem", "America/Belem"),
+ ("America/Belize", "America/Belize"),
+ ("America/Blanc-Sablon", "America/Blanc-Sablon"),
+ ("America/Boa_Vista", "America/Boa_Vista"),
+ ("America/Bogota", "America/Bogota"),
+ ("America/Boise", "America/Boise"),
+ ("America/Buenos_Aires", "America/Buenos_Aires"),
+ ("America/Cambridge_Bay", "America/Cambridge_Bay"),
+ ("America/Campo_Grande", "America/Campo_Grande"),
+ ("America/Cancun", "America/Cancun"),
+ ("America/Caracas", "America/Caracas"),
+ ("America/Catamarca", "America/Catamarca"),
+ ("America/Cayenne", "America/Cayenne"),
+ ("America/Cayman", "America/Cayman"),
+ ("America/Chicago", "America/Chicago"),
+ ("America/Chihuahua", "America/Chihuahua"),
+ ("America/Coral_Harbour", "America/Coral_Harbour"),
+ ("America/Cordoba", "America/Cordoba"),
+ ("America/Costa_Rica", "America/Costa_Rica"),
+ ("America/Creston", "America/Creston"),
+ ("America/Cuiaba", "America/Cuiaba"),
+ ("America/Curacao", "America/Curacao"),
+ ("America/Danmarkshavn", "America/Danmarkshavn"),
+ ("America/Dawson", "America/Dawson"),
+ ("America/Dawson_Creek", "America/Dawson_Creek"),
+ ("America/Denver", "America/Denver"),
+ ("America/Detroit", "America/Detroit"),
+ ("America/Dominica", "America/Dominica"),
+ ("America/Edmonton", "America/Edmonton"),
+ ("America/Eirunepe", "America/Eirunepe"),
+ ("America/El_Salvador", "America/El_Salvador"),
+ ("America/Ensenada", "America/Ensenada"),
+ ("America/Fort_Nelson", "America/Fort_Nelson"),
+ ("America/Fort_Wayne", "America/Fort_Wayne"),
+ ("America/Fortaleza", "America/Fortaleza"),
+ ("America/Glace_Bay", "America/Glace_Bay"),
+ ("America/Godthab", "America/Godthab"),
+ ("America/Goose_Bay", "America/Goose_Bay"),
+ ("America/Grand_Turk", "America/Grand_Turk"),
+ ("America/Grenada", "America/Grenada"),
+ ("America/Guadeloupe", "America/Guadeloupe"),
+ ("America/Guatemala", "America/Guatemala"),
+ ("America/Guayaquil", "America/Guayaquil"),
+ ("America/Guyana", "America/Guyana"),
+ ("America/Halifax", "America/Halifax"),
+ ("America/Havana", "America/Havana"),
+ ("America/Hermosillo", "America/Hermosillo"),
+ ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
+ ("America/Indiana/Knox", "America/Indiana/Knox"),
+ ("America/Indiana/Marengo", "America/Indiana/Marengo"),
+ ("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
+ ("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
+ ("America/Indiana/Vevay", "America/Indiana/Vevay"),
+ ("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
+ ("America/Indiana/Winamac", "America/Indiana/Winamac"),
+ ("America/Indianapolis", "America/Indianapolis"),
+ ("America/Inuvik", "America/Inuvik"),
+ ("America/Iqaluit", "America/Iqaluit"),
+ ("America/Jamaica", "America/Jamaica"),
+ ("America/Jujuy", "America/Jujuy"),
+ ("America/Juneau", "America/Juneau"),
+ ("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
+ ("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
+ ("America/Knox_IN", "America/Knox_IN"),
+ ("America/Kralendijk", "America/Kralendijk"),
+ ("America/La_Paz", "America/La_Paz"),
+ ("America/Lima", "America/Lima"),
+ ("America/Los_Angeles", "America/Los_Angeles"),
+ ("America/Louisville", "America/Louisville"),
+ ("America/Lower_Princes", "America/Lower_Princes"),
+ ("America/Maceio", "America/Maceio"),
+ ("America/Managua", "America/Managua"),
+ ("America/Manaus", "America/Manaus"),
+ ("America/Marigot", "America/Marigot"),
+ ("America/Martinique", "America/Martinique"),
+ ("America/Matamoros", "America/Matamoros"),
+ ("America/Mazatlan", "America/Mazatlan"),
+ ("America/Mendoza", "America/Mendoza"),
+ ("America/Menominee", "America/Menominee"),
+ ("America/Merida", "America/Merida"),
+ ("America/Metlakatla", "America/Metlakatla"),
+ ("America/Mexico_City", "America/Mexico_City"),
+ ("America/Miquelon", "America/Miquelon"),
+ ("America/Moncton", "America/Moncton"),
+ ("America/Monterrey", "America/Monterrey"),
+ ("America/Montevideo", "America/Montevideo"),
+ ("America/Montreal", "America/Montreal"),
+ ("America/Montserrat", "America/Montserrat"),
+ ("America/Nassau", "America/Nassau"),
+ ("America/New_York", "America/New_York"),
+ ("America/Nipigon", "America/Nipigon"),
+ ("America/Nome", "America/Nome"),
+ ("America/Noronha", "America/Noronha"),
+ ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
+ ("America/North_Dakota/Center", "America/North_Dakota/Center"),
+ (
+ "America/North_Dakota/New_Salem",
+ "America/North_Dakota/New_Salem",
+ ),
+ ("America/Nuuk", "America/Nuuk"),
+ ("America/Ojinaga", "America/Ojinaga"),
+ ("America/Panama", "America/Panama"),
+ ("America/Pangnirtung", "America/Pangnirtung"),
+ ("America/Paramaribo", "America/Paramaribo"),
+ ("America/Phoenix", "America/Phoenix"),
+ ("America/Port-au-Prince", "America/Port-au-Prince"),
+ ("America/Port_of_Spain", "America/Port_of_Spain"),
+ ("America/Porto_Acre", "America/Porto_Acre"),
+ ("America/Porto_Velho", "America/Porto_Velho"),
+ ("America/Puerto_Rico", "America/Puerto_Rico"),
+ ("America/Punta_Arenas", "America/Punta_Arenas"),
+ ("America/Rainy_River", "America/Rainy_River"),
+ ("America/Rankin_Inlet", "America/Rankin_Inlet"),
+ ("America/Recife", "America/Recife"),
+ ("America/Regina", "America/Regina"),
+ ("America/Resolute", "America/Resolute"),
+ ("America/Rio_Branco", "America/Rio_Branco"),
+ ("America/Rosario", "America/Rosario"),
+ ("America/Santa_Isabel", "America/Santa_Isabel"),
+ ("America/Santarem", "America/Santarem"),
+ ("America/Santiago", "America/Santiago"),
+ ("America/Santo_Domingo", "America/Santo_Domingo"),
+ ("America/Sao_Paulo", "America/Sao_Paulo"),
+ ("America/Scoresbysund", "America/Scoresbysund"),
+ ("America/Shiprock", "America/Shiprock"),
+ ("America/Sitka", "America/Sitka"),
+ ("America/St_Barthelemy", "America/St_Barthelemy"),
+ ("America/St_Johns", "America/St_Johns"),
+ ("America/St_Kitts", "America/St_Kitts"),
+ ("America/St_Lucia", "America/St_Lucia"),
+ ("America/St_Thomas", "America/St_Thomas"),
+ ("America/St_Vincent", "America/St_Vincent"),
+ ("America/Swift_Current", "America/Swift_Current"),
+ ("America/Tegucigalpa", "America/Tegucigalpa"),
+ ("America/Thule", "America/Thule"),
+ ("America/Thunder_Bay", "America/Thunder_Bay"),
+ ("America/Tijuana", "America/Tijuana"),
+ ("America/Toronto", "America/Toronto"),
+ ("America/Tortola", "America/Tortola"),
+ ("America/Vancouver", "America/Vancouver"),
+ ("America/Virgin", "America/Virgin"),
+ ("America/Whitehorse", "America/Whitehorse"),
+ ("America/Winnipeg", "America/Winnipeg"),
+ ("America/Yakutat", "America/Yakutat"),
+ ("America/Yellowknife", "America/Yellowknife"),
+ ("Antarctica/Casey", "Antarctica/Casey"),
+ ("Antarctica/Davis", "Antarctica/Davis"),
+ ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
+ ("Antarctica/Macquarie", "Antarctica/Macquarie"),
+ ("Antarctica/Mawson", "Antarctica/Mawson"),
+ ("Antarctica/McMurdo", "Antarctica/McMurdo"),
+ ("Antarctica/Palmer", "Antarctica/Palmer"),
+ ("Antarctica/Rothera", "Antarctica/Rothera"),
+ ("Antarctica/South_Pole", "Antarctica/South_Pole"),
+ ("Antarctica/Syowa", "Antarctica/Syowa"),
+ ("Antarctica/Troll", "Antarctica/Troll"),
+ ("Antarctica/Vostok", "Antarctica/Vostok"),
+ ("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
+ ("Asia/Aden", "Asia/Aden"),
+ ("Asia/Almaty", "Asia/Almaty"),
+ ("Asia/Amman", "Asia/Amman"),
+ ("Asia/Anadyr", "Asia/Anadyr"),
+ ("Asia/Aqtau", "Asia/Aqtau"),
+ ("Asia/Aqtobe", "Asia/Aqtobe"),
+ ("Asia/Ashgabat", "Asia/Ashgabat"),
+ ("Asia/Ashkhabad", "Asia/Ashkhabad"),
+ ("Asia/Atyrau", "Asia/Atyrau"),
+ ("Asia/Baghdad", "Asia/Baghdad"),
+ ("Asia/Bahrain", "Asia/Bahrain"),
+ ("Asia/Baku", "Asia/Baku"),
+ ("Asia/Bangkok", "Asia/Bangkok"),
+ ("Asia/Barnaul", "Asia/Barnaul"),
+ ("Asia/Beirut", "Asia/Beirut"),
+ ("Asia/Bishkek", "Asia/Bishkek"),
+ ("Asia/Brunei", "Asia/Brunei"),
+ ("Asia/Calcutta", "Asia/Calcutta"),
+ ("Asia/Chita", "Asia/Chita"),
+ ("Asia/Choibalsan", "Asia/Choibalsan"),
+ ("Asia/Chongqing", "Asia/Chongqing"),
+ ("Asia/Chungking", "Asia/Chungking"),
+ ("Asia/Colombo", "Asia/Colombo"),
+ ("Asia/Dacca", "Asia/Dacca"),
+ ("Asia/Damascus", "Asia/Damascus"),
+ ("Asia/Dhaka", "Asia/Dhaka"),
+ ("Asia/Dili", "Asia/Dili"),
+ ("Asia/Dubai", "Asia/Dubai"),
+ ("Asia/Dushanbe", "Asia/Dushanbe"),
+ ("Asia/Famagusta", "Asia/Famagusta"),
+ ("Asia/Gaza", "Asia/Gaza"),
+ ("Asia/Harbin", "Asia/Harbin"),
+ ("Asia/Hebron", "Asia/Hebron"),
+ ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
+ ("Asia/Hong_Kong", "Asia/Hong_Kong"),
+ ("Asia/Hovd", "Asia/Hovd"),
+ ("Asia/Irkutsk", "Asia/Irkutsk"),
+ ("Asia/Istanbul", "Asia/Istanbul"),
+ ("Asia/Jakarta", "Asia/Jakarta"),
+ ("Asia/Jayapura", "Asia/Jayapura"),
+ ("Asia/Jerusalem", "Asia/Jerusalem"),
+ ("Asia/Kabul", "Asia/Kabul"),
+ ("Asia/Kamchatka", "Asia/Kamchatka"),
+ ("Asia/Karachi", "Asia/Karachi"),
+ ("Asia/Kashgar", "Asia/Kashgar"),
+ ("Asia/Kathmandu", "Asia/Kathmandu"),
+ ("Asia/Katmandu", "Asia/Katmandu"),
+ ("Asia/Khandyga", "Asia/Khandyga"),
+ ("Asia/Kolkata", "Asia/Kolkata"),
+ ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
+ ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
+ ("Asia/Kuching", "Asia/Kuching"),
+ ("Asia/Kuwait", "Asia/Kuwait"),
+ ("Asia/Macao", "Asia/Macao"),
+ ("Asia/Macau", "Asia/Macau"),
+ ("Asia/Magadan", "Asia/Magadan"),
+ ("Asia/Makassar", "Asia/Makassar"),
+ ("Asia/Manila", "Asia/Manila"),
+ ("Asia/Muscat", "Asia/Muscat"),
+ ("Asia/Nicosia", "Asia/Nicosia"),
+ ("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
+ ("Asia/Novosibirsk", "Asia/Novosibirsk"),
+ ("Asia/Omsk", "Asia/Omsk"),
+ ("Asia/Oral", "Asia/Oral"),
+ ("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
+ ("Asia/Pontianak", "Asia/Pontianak"),
+ ("Asia/Pyongyang", "Asia/Pyongyang"),
+ ("Asia/Qatar", "Asia/Qatar"),
+ ("Asia/Qostanay", "Asia/Qostanay"),
+ ("Asia/Qyzylorda", "Asia/Qyzylorda"),
+ ("Asia/Rangoon", "Asia/Rangoon"),
+ ("Asia/Riyadh", "Asia/Riyadh"),
+ ("Asia/Saigon", "Asia/Saigon"),
+ ("Asia/Sakhalin", "Asia/Sakhalin"),
+ ("Asia/Samarkand", "Asia/Samarkand"),
+ ("Asia/Seoul", "Asia/Seoul"),
+ ("Asia/Shanghai", "Asia/Shanghai"),
+ ("Asia/Singapore", "Asia/Singapore"),
+ ("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
+ ("Asia/Taipei", "Asia/Taipei"),
+ ("Asia/Tashkent", "Asia/Tashkent"),
+ ("Asia/Tbilisi", "Asia/Tbilisi"),
+ ("Asia/Tehran", "Asia/Tehran"),
+ ("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
+ ("Asia/Thimbu", "Asia/Thimbu"),
+ ("Asia/Thimphu", "Asia/Thimphu"),
+ ("Asia/Tokyo", "Asia/Tokyo"),
+ ("Asia/Tomsk", "Asia/Tomsk"),
+ ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
+ ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
+ ("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
+ ("Asia/Urumqi", "Asia/Urumqi"),
+ ("Asia/Ust-Nera", "Asia/Ust-Nera"),
+ ("Asia/Vientiane", "Asia/Vientiane"),
+ ("Asia/Vladivostok", "Asia/Vladivostok"),
+ ("Asia/Yakutsk", "Asia/Yakutsk"),
+ ("Asia/Yangon", "Asia/Yangon"),
+ ("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
+ ("Asia/Yerevan", "Asia/Yerevan"),
+ ("Atlantic/Azores", "Atlantic/Azores"),
+ ("Atlantic/Bermuda", "Atlantic/Bermuda"),
+ ("Atlantic/Canary", "Atlantic/Canary"),
+ ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
+ ("Atlantic/Faeroe", "Atlantic/Faeroe"),
+ ("Atlantic/Faroe", "Atlantic/Faroe"),
+ ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
+ ("Atlantic/Madeira", "Atlantic/Madeira"),
+ ("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
+ ("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
+ ("Atlantic/St_Helena", "Atlantic/St_Helena"),
+ ("Atlantic/Stanley", "Atlantic/Stanley"),
+ ("Australia/ACT", "Australia/ACT"),
+ ("Australia/Adelaide", "Australia/Adelaide"),
+ ("Australia/Brisbane", "Australia/Brisbane"),
+ ("Australia/Broken_Hill", "Australia/Broken_Hill"),
+ ("Australia/Canberra", "Australia/Canberra"),
+ ("Australia/Currie", "Australia/Currie"),
+ ("Australia/Darwin", "Australia/Darwin"),
+ ("Australia/Eucla", "Australia/Eucla"),
+ ("Australia/Hobart", "Australia/Hobart"),
+ ("Australia/LHI", "Australia/LHI"),
+ ("Australia/Lindeman", "Australia/Lindeman"),
+ ("Australia/Lord_Howe", "Australia/Lord_Howe"),
+ ("Australia/Melbourne", "Australia/Melbourne"),
+ ("Australia/NSW", "Australia/NSW"),
+ ("Australia/North", "Australia/North"),
+ ("Australia/Perth", "Australia/Perth"),
+ ("Australia/Queensland", "Australia/Queensland"),
+ ("Australia/South", "Australia/South"),
+ ("Australia/Sydney", "Australia/Sydney"),
+ ("Australia/Tasmania", "Australia/Tasmania"),
+ ("Australia/Victoria", "Australia/Victoria"),
+ ("Australia/West", "Australia/West"),
+ ("Australia/Yancowinna", "Australia/Yancowinna"),
+ ("Brazil/Acre", "Brazil/Acre"),
+ ("Brazil/DeNoronha", "Brazil/DeNoronha"),
+ ("Brazil/East", "Brazil/East"),
+ ("Brazil/West", "Brazil/West"),
+ ("CET", "CET"),
+ ("CST6CDT", "CST6CDT"),
+ ("Canada/Atlantic", "Canada/Atlantic"),
+ ("Canada/Central", "Canada/Central"),
+ ("Canada/Eastern", "Canada/Eastern"),
+ ("Canada/Mountain", "Canada/Mountain"),
+ ("Canada/Newfoundland", "Canada/Newfoundland"),
+ ("Canada/Pacific", "Canada/Pacific"),
+ ("Canada/Saskatchewan", "Canada/Saskatchewan"),
+ ("Canada/Yukon", "Canada/Yukon"),
+ ("Chile/Continental", "Chile/Continental"),
+ ("Chile/EasterIsland", "Chile/EasterIsland"),
+ ("Cuba", "Cuba"),
+ ("EET", "EET"),
+ ("EST", "EST"),
+ ("EST5EDT", "EST5EDT"),
+ ("Egypt", "Egypt"),
+ ("Eire", "Eire"),
+ ("Etc/GMT", "Etc/GMT"),
+ ("Etc/GMT+0", "Etc/GMT+0"),
+ ("Etc/GMT+1", "Etc/GMT+1"),
+ ("Etc/GMT+10", "Etc/GMT+10"),
+ ("Etc/GMT+11", "Etc/GMT+11"),
+ ("Etc/GMT+12", "Etc/GMT+12"),
+ ("Etc/GMT+2", "Etc/GMT+2"),
+ ("Etc/GMT+3", "Etc/GMT+3"),
+ ("Etc/GMT+4", "Etc/GMT+4"),
+ ("Etc/GMT+5", "Etc/GMT+5"),
+ ("Etc/GMT+6", "Etc/GMT+6"),
+ ("Etc/GMT+7", "Etc/GMT+7"),
+ ("Etc/GMT+8", "Etc/GMT+8"),
+ ("Etc/GMT+9", "Etc/GMT+9"),
+ ("Etc/GMT-0", "Etc/GMT-0"),
+ ("Etc/GMT-1", "Etc/GMT-1"),
+ ("Etc/GMT-10", "Etc/GMT-10"),
+ ("Etc/GMT-11", "Etc/GMT-11"),
+ ("Etc/GMT-12", "Etc/GMT-12"),
+ ("Etc/GMT-13", "Etc/GMT-13"),
+ ("Etc/GMT-14", "Etc/GMT-14"),
+ ("Etc/GMT-2", "Etc/GMT-2"),
+ ("Etc/GMT-3", "Etc/GMT-3"),
+ ("Etc/GMT-4", "Etc/GMT-4"),
+ ("Etc/GMT-5", "Etc/GMT-5"),
+ ("Etc/GMT-6", "Etc/GMT-6"),
+ ("Etc/GMT-7", "Etc/GMT-7"),
+ ("Etc/GMT-8", "Etc/GMT-8"),
+ ("Etc/GMT-9", "Etc/GMT-9"),
+ ("Etc/GMT0", "Etc/GMT0"),
+ ("Etc/Greenwich", "Etc/Greenwich"),
+ ("Etc/UCT", "Etc/UCT"),
+ ("Etc/UTC", "Etc/UTC"),
+ ("Etc/Universal", "Etc/Universal"),
+ ("Etc/Zulu", "Etc/Zulu"),
+ ("Europe/Amsterdam", "Europe/Amsterdam"),
+ ("Europe/Andorra", "Europe/Andorra"),
+ ("Europe/Astrakhan", "Europe/Astrakhan"),
+ ("Europe/Athens", "Europe/Athens"),
+ ("Europe/Belfast", "Europe/Belfast"),
+ ("Europe/Belgrade", "Europe/Belgrade"),
+ ("Europe/Berlin", "Europe/Berlin"),
+ ("Europe/Bratislava", "Europe/Bratislava"),
+ ("Europe/Brussels", "Europe/Brussels"),
+ ("Europe/Bucharest", "Europe/Bucharest"),
+ ("Europe/Budapest", "Europe/Budapest"),
+ ("Europe/Busingen", "Europe/Busingen"),
+ ("Europe/Chisinau", "Europe/Chisinau"),
+ ("Europe/Copenhagen", "Europe/Copenhagen"),
+ ("Europe/Dublin", "Europe/Dublin"),
+ ("Europe/Gibraltar", "Europe/Gibraltar"),
+ ("Europe/Guernsey", "Europe/Guernsey"),
+ ("Europe/Helsinki", "Europe/Helsinki"),
+ ("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
+ ("Europe/Istanbul", "Europe/Istanbul"),
+ ("Europe/Jersey", "Europe/Jersey"),
+ ("Europe/Kaliningrad", "Europe/Kaliningrad"),
+ ("Europe/Kiev", "Europe/Kiev"),
+ ("Europe/Kirov", "Europe/Kirov"),
+ ("Europe/Lisbon", "Europe/Lisbon"),
+ ("Europe/Ljubljana", "Europe/Ljubljana"),
+ ("Europe/London", "Europe/London"),
+ ("Europe/Luxembourg", "Europe/Luxembourg"),
+ ("Europe/Madrid", "Europe/Madrid"),
+ ("Europe/Malta", "Europe/Malta"),
+ ("Europe/Mariehamn", "Europe/Mariehamn"),
+ ("Europe/Minsk", "Europe/Minsk"),
+ ("Europe/Monaco", "Europe/Monaco"),
+ ("Europe/Moscow", "Europe/Moscow"),
+ ("Europe/Nicosia", "Europe/Nicosia"),
+ ("Europe/Oslo", "Europe/Oslo"),
+ ("Europe/Paris", "Europe/Paris"),
+ ("Europe/Podgorica", "Europe/Podgorica"),
+ ("Europe/Prague", "Europe/Prague"),
+ ("Europe/Riga", "Europe/Riga"),
+ ("Europe/Rome", "Europe/Rome"),
+ ("Europe/Samara", "Europe/Samara"),
+ ("Europe/San_Marino", "Europe/San_Marino"),
+ ("Europe/Sarajevo", "Europe/Sarajevo"),
+ ("Europe/Saratov", "Europe/Saratov"),
+ ("Europe/Simferopol", "Europe/Simferopol"),
+ ("Europe/Skopje", "Europe/Skopje"),
+ ("Europe/Sofia", "Europe/Sofia"),
+ ("Europe/Stockholm", "Europe/Stockholm"),
+ ("Europe/Tallinn", "Europe/Tallinn"),
+ ("Europe/Tirane", "Europe/Tirane"),
+ ("Europe/Tiraspol", "Europe/Tiraspol"),
+ ("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
+ ("Europe/Uzhgorod", "Europe/Uzhgorod"),
+ ("Europe/Vaduz", "Europe/Vaduz"),
+ ("Europe/Vatican", "Europe/Vatican"),
+ ("Europe/Vienna", "Europe/Vienna"),
+ ("Europe/Vilnius", "Europe/Vilnius"),
+ ("Europe/Volgograd", "Europe/Volgograd"),
+ ("Europe/Warsaw", "Europe/Warsaw"),
+ ("Europe/Zagreb", "Europe/Zagreb"),
+ ("Europe/Zaporozhye", "Europe/Zaporozhye"),
+ ("Europe/Zurich", "Europe/Zurich"),
+ ("GB", "GB"),
+ ("GB-Eire", "GB-Eire"),
+ ("GMT", "GMT"),
+ ("GMT+0", "GMT+0"),
+ ("GMT-0", "GMT-0"),
+ ("GMT0", "GMT0"),
+ ("Greenwich", "Greenwich"),
+ ("HST", "HST"),
+ ("Hongkong", "Hongkong"),
+ ("Iceland", "Iceland"),
+ ("Indian/Antananarivo", "Indian/Antananarivo"),
+ ("Indian/Chagos", "Indian/Chagos"),
+ ("Indian/Christmas", "Indian/Christmas"),
+ ("Indian/Cocos", "Indian/Cocos"),
+ ("Indian/Comoro", "Indian/Comoro"),
+ ("Indian/Kerguelen", "Indian/Kerguelen"),
+ ("Indian/Mahe", "Indian/Mahe"),
+ ("Indian/Maldives", "Indian/Maldives"),
+ ("Indian/Mauritius", "Indian/Mauritius"),
+ ("Indian/Mayotte", "Indian/Mayotte"),
+ ("Indian/Reunion", "Indian/Reunion"),
+ ("Iran", "Iran"),
+ ("Israel", "Israel"),
+ ("Jamaica", "Jamaica"),
+ ("Japan", "Japan"),
+ ("Kwajalein", "Kwajalein"),
+ ("Libya", "Libya"),
+ ("MET", "MET"),
+ ("MST", "MST"),
+ ("MST7MDT", "MST7MDT"),
+ ("Mexico/BajaNorte", "Mexico/BajaNorte"),
+ ("Mexico/BajaSur", "Mexico/BajaSur"),
+ ("Mexico/General", "Mexico/General"),
+ ("NZ", "NZ"),
+ ("NZ-CHAT", "NZ-CHAT"),
+ ("Navajo", "Navajo"),
+ ("PRC", "PRC"),
+ ("PST8PDT", "PST8PDT"),
+ ("Pacific/Apia", "Pacific/Apia"),
+ ("Pacific/Auckland", "Pacific/Auckland"),
+ ("Pacific/Bougainville", "Pacific/Bougainville"),
+ ("Pacific/Chatham", "Pacific/Chatham"),
+ ("Pacific/Chuuk", "Pacific/Chuuk"),
+ ("Pacific/Easter", "Pacific/Easter"),
+ ("Pacific/Efate", "Pacific/Efate"),
+ ("Pacific/Enderbury", "Pacific/Enderbury"),
+ ("Pacific/Fakaofo", "Pacific/Fakaofo"),
+ ("Pacific/Fiji", "Pacific/Fiji"),
+ ("Pacific/Funafuti", "Pacific/Funafuti"),
+ ("Pacific/Galapagos", "Pacific/Galapagos"),
+ ("Pacific/Gambier", "Pacific/Gambier"),
+ ("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
+ ("Pacific/Guam", "Pacific/Guam"),
+ ("Pacific/Honolulu", "Pacific/Honolulu"),
+ ("Pacific/Johnston", "Pacific/Johnston"),
+ ("Pacific/Kiritimati", "Pacific/Kiritimati"),
+ ("Pacific/Kosrae", "Pacific/Kosrae"),
+ ("Pacific/Kwajalein", "Pacific/Kwajalein"),
+ ("Pacific/Majuro", "Pacific/Majuro"),
+ ("Pacific/Marquesas", "Pacific/Marquesas"),
+ ("Pacific/Midway", "Pacific/Midway"),
+ ("Pacific/Nauru", "Pacific/Nauru"),
+ ("Pacific/Niue", "Pacific/Niue"),
+ ("Pacific/Norfolk", "Pacific/Norfolk"),
+ ("Pacific/Noumea", "Pacific/Noumea"),
+ ("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
+ ("Pacific/Palau", "Pacific/Palau"),
+ ("Pacific/Pitcairn", "Pacific/Pitcairn"),
+ ("Pacific/Pohnpei", "Pacific/Pohnpei"),
+ ("Pacific/Ponape", "Pacific/Ponape"),
+ ("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
+ ("Pacific/Rarotonga", "Pacific/Rarotonga"),
+ ("Pacific/Saipan", "Pacific/Saipan"),
+ ("Pacific/Samoa", "Pacific/Samoa"),
+ ("Pacific/Tahiti", "Pacific/Tahiti"),
+ ("Pacific/Tarawa", "Pacific/Tarawa"),
+ ("Pacific/Tongatapu", "Pacific/Tongatapu"),
+ ("Pacific/Truk", "Pacific/Truk"),
+ ("Pacific/Wake", "Pacific/Wake"),
+ ("Pacific/Wallis", "Pacific/Wallis"),
+ ("Pacific/Yap", "Pacific/Yap"),
+ ("Poland", "Poland"),
+ ("Portugal", "Portugal"),
+ ("ROC", "ROC"),
+ ("ROK", "ROK"),
+ ("Singapore", "Singapore"),
+ ("Turkey", "Turkey"),
+ ("UCT", "UCT"),
+ ("US/Alaska", "US/Alaska"),
+ ("US/Aleutian", "US/Aleutian"),
+ ("US/Arizona", "US/Arizona"),
+ ("US/Central", "US/Central"),
+ ("US/East-Indiana", "US/East-Indiana"),
+ ("US/Eastern", "US/Eastern"),
+ ("US/Hawaii", "US/Hawaii"),
+ ("US/Indiana-Starke", "US/Indiana-Starke"),
+ ("US/Michigan", "US/Michigan"),
+ ("US/Mountain", "US/Mountain"),
+ ("US/Pacific", "US/Pacific"),
+ ("US/Samoa", "US/Samoa"),
+ ("UTC", "UTC"),
+ ("Universal", "Universal"),
+ ("W-SU", "W-SU"),
+ ("WET", "WET"),
+ ("Zulu", "Zulu"),
+ ],
+ default="UTC",
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0060_siteinvite_invitees.py b/bookwyrm/migrations/0060_siteinvite_invitees.py
new file mode 100644
index 00000000..acd00977
--- /dev/null
+++ b/bookwyrm/migrations/0060_siteinvite_invitees.py
@@ -0,0 +1,21 @@
+# Generated by Django 3.1.6 on 2021-04-02 00:14
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0059_user_preferred_timezone"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="siteinvite",
+ name="invitees",
+ field=models.ManyToManyField(
+ related_name="invitees", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0061_auto_20210402_1435.py b/bookwyrm/migrations/0061_auto_20210402_1435.py
new file mode 100644
index 00000000..a6899aa3
--- /dev/null
+++ b/bookwyrm/migrations/0061_auto_20210402_1435.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.1.6 on 2021-04-02 14:35
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0060_siteinvite_invitees"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="outbox",
+ field=bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0062_auto_20210406_1731.py b/bookwyrm/migrations/0062_auto_20210406_1731.py
new file mode 100644
index 00000000..5db176ec
--- /dev/null
+++ b/bookwyrm/migrations/0062_auto_20210406_1731.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.1.6 on 2021-04-06 17:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0061_auto_20210402_1435"),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="connector",
+ name="connector_file_valid",
+ ),
+ migrations.AlterField(
+ model_name="connector",
+ name="connector_file",
+ field=models.CharField(
+ choices=[
+ ("openlibrary", "Openlibrary"),
+ ("inventaire", "Inventaire"),
+ ("self_connector", "Self Connector"),
+ ("bookwyrm_connector", "Bookwyrm Connector"),
+ ],
+ max_length=255,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0062_auto_20210407_1545.py b/bookwyrm/migrations/0062_auto_20210407_1545.py
new file mode 100644
index 00000000..3a156637
--- /dev/null
+++ b/bookwyrm/migrations/0062_auto_20210407_1545.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.6 on 2021-04-07 15:45
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0061_auto_20210402_1435"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="book",
+ name="series",
+ field=bookwyrm.models.fields.TextField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="book",
+ name="subtitle",
+ field=bookwyrm.models.fields.TextField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AlterField(
+ model_name="book",
+ name="title",
+ field=bookwyrm.models.fields.TextField(max_length=255),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0063_auto_20210407_0045.py b/bookwyrm/migrations/0063_auto_20210407_0045.py
new file mode 100644
index 00000000..cd87dd97
--- /dev/null
+++ b/bookwyrm/migrations/0063_auto_20210407_0045.py
@@ -0,0 +1,63 @@
+# Generated by Django 3.1.6 on 2021-04-07 00:45
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0062_auto_20210406_1731"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="author",
+ name="bnf_id",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="author",
+ name="gutenberg_id",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="author",
+ name="inventaire_id",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="author",
+ name="isni",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="author",
+ name="viaf_id",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="book",
+ name="bnf_id",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ migrations.AddField(
+ model_name="book",
+ name="inventaire_id",
+ field=bookwyrm.models.fields.CharField(
+ blank=True, max_length=255, null=True
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0063_auto_20210407_1827.py b/bookwyrm/migrations/0063_auto_20210407_1827.py
new file mode 100644
index 00000000..0bd0f2ae
--- /dev/null
+++ b/bookwyrm/migrations/0063_auto_20210407_1827.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0063_auto_20210408_1556.py b/bookwyrm/migrations/0063_auto_20210408_1556.py
new file mode 100644
index 00000000..750997fb
--- /dev/null
+++ b/bookwyrm/migrations/0063_auto_20210408_1556.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1.6 on 2021-04-08 15:56
+
+import bookwyrm.models.fields
+import django.contrib.postgres.fields.citext
+import django.contrib.postgres.operations
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0062_auto_20210407_1545"),
+ ]
+
+ operations = [
+ django.contrib.postgres.operations.CITextExtension(),
+ migrations.AlterField(
+ model_name="user",
+ name="localname",
+ field=django.contrib.postgres.fields.citext.CICharField(
+ max_length=255,
+ null=True,
+ unique=True,
+ validators=[bookwyrm.models.fields.validate_localname],
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0064_auto_20210408_2208.py b/bookwyrm/migrations/0064_auto_20210408_2208.py
new file mode 100644
index 00000000..84a1a128
--- /dev/null
+++ b/bookwyrm/migrations/0064_auto_20210408_2208.py
@@ -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"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0064_merge_20210410_1633.py b/bookwyrm/migrations/0064_merge_20210410_1633.py
new file mode 100644
index 00000000..77ad541e
--- /dev/null
+++ b/bookwyrm/migrations/0064_merge_20210410_1633.py
@@ -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 = []
diff --git a/bookwyrm/migrations/0065_merge_20210411_1702.py b/bookwyrm/migrations/0065_merge_20210411_1702.py
new file mode 100644
index 00000000..2bdc425d
--- /dev/null
+++ b/bookwyrm/migrations/0065_merge_20210411_1702.py
@@ -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 = []
diff --git a/bookwyrm/migrations/0066_user_deactivation_reason.py b/bookwyrm/migrations/0066_user_deactivation_reason.py
new file mode 100644
index 00000000..bb3173a7
--- /dev/null
+++ b/bookwyrm/migrations/0066_user_deactivation_reason.py
@@ -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,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0067_denullify_list_item_order.py b/bookwyrm/migrations/0067_denullify_list_item_order.py
new file mode 100644
index 00000000..51e28371
--- /dev/null
+++ b/bookwyrm/migrations/0067_denullify_list_item_order.py
@@ -0,0 +1,30 @@
+from django.db import migrations
+
+
+def forwards_func(apps, schema_editor):
+ # Set all values for ListItem.order
+ BookList = apps.get_model("bookwyrm", "List")
+ db_alias = schema_editor.connection.alias
+ for book_list in BookList.objects.using(db_alias).all():
+ for i, item in enumerate(book_list.listitem_set.order_by("id"), 1):
+ item.order = i
+ item.save()
+
+
+def reverse_func(apps, schema_editor):
+ # null all values for ListItem.order
+ BookList = apps.get_model("bookwyrm", "List")
+ db_alias = schema_editor.connection.alias
+ for book_list in BookList.objects.using(db_alias).all():
+ for item in book_list.listitem_set.order_by("id"):
+ item.order = None
+ item.save()
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0066_user_deactivation_reason"),
+ ]
+
+ operations = [migrations.RunPython(forwards_func, reverse_func)]
diff --git a/bookwyrm/migrations/0068_ordering_for_list_items.py b/bookwyrm/migrations/0068_ordering_for_list_items.py
new file mode 100644
index 00000000..fa64f13c
--- /dev/null
+++ b/bookwyrm/migrations/0068_ordering_for_list_items.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.1.6 on 2021-04-08 16:15
+
+import bookwyrm.models.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0067_denullify_list_item_order"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="listitem",
+ name="order",
+ field=bookwyrm.models.fields.IntegerField(),
+ ),
+ migrations.AlterUniqueTogether(
+ name="listitem",
+ unique_together={("order", "book_list"), ("book", "book_list")},
+ ),
+ ]
diff --git a/bookwyrm/migrations/0069_auto_20210422_1604.py b/bookwyrm/migrations/0069_auto_20210422_1604.py
new file mode 100644
index 00000000..6591e7b9
--- /dev/null
+++ b/bookwyrm/migrations/0069_auto_20210422_1604.py
@@ -0,0 +1,34 @@
+# Generated by Django 3.1.8 on 2021-04-22 16:04
+
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0068_ordering_for_list_items"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="author",
+ name="last_edited_by",
+ field=bookwyrm.models.fields.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="book",
+ name="last_edited_by",
+ field=bookwyrm.models.fields.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0070_auto_20210423_0121.py b/bookwyrm/migrations/0070_auto_20210423_0121.py
new file mode 100644
index 00000000..0b04c3ca
--- /dev/null
+++ b/bookwyrm/migrations/0070_auto_20210423_0121.py
@@ -0,0 +1,35 @@
+# Generated by Django 3.1.8 on 2021-04-23 01:21
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0069_auto_20210422_1604"),
+ ]
+
+ operations = [
+ migrations.AlterUniqueTogether(
+ name="usertag",
+ unique_together=None,
+ ),
+ migrations.RemoveField(
+ model_name="usertag",
+ name="book",
+ ),
+ migrations.RemoveField(
+ model_name="usertag",
+ name="tag",
+ ),
+ migrations.RemoveField(
+ model_name="usertag",
+ name="user",
+ ),
+ migrations.DeleteModel(
+ name="Tag",
+ ),
+ migrations.DeleteModel(
+ name="UserTag",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121.py b/bookwyrm/migrations/0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121.py
new file mode 100644
index 00000000..b6489b80
--- /dev/null
+++ b/bookwyrm/migrations/0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2 on 2021-04-26 21:32
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0063_auto_20210407_0045"),
+ ("bookwyrm", "0070_auto_20210423_0121"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0072_remove_work_default_edition.py b/bookwyrm/migrations/0072_remove_work_default_edition.py
new file mode 100644
index 00000000..1c05c95e
--- /dev/null
+++ b/bookwyrm/migrations/0072_remove_work_default_edition.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2 on 2021-04-28 22:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="work",
+ name="default_edition",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0073_sitesettings_footer_item.py b/bookwyrm/migrations/0073_sitesettings_footer_item.py
new file mode 100644
index 00000000..4aa8384f
--- /dev/null
+++ b/bookwyrm/migrations/0073_sitesettings_footer_item.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2 on 2021-04-30 17:25
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0072_remove_work_default_edition"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="footer_item",
+ field=models.TextField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0074_auto_20210511_1829.py b/bookwyrm/migrations/0074_auto_20210511_1829.py
new file mode 100644
index 00000000..287a51ad
--- /dev/null
+++ b/bookwyrm/migrations/0074_auto_20210511_1829.py
@@ -0,0 +1,48 @@
+# Generated by Django 3.2 on 2021-05-11 18:29
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0073_sitesettings_footer_item"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="connector",
+ name="max_query_count",
+ ),
+ migrations.RemoveField(
+ model_name="connector",
+ name="politeness_delay",
+ ),
+ migrations.RemoveField(
+ model_name="connector",
+ name="query_count",
+ ),
+ migrations.RemoveField(
+ model_name="connector",
+ name="query_count_expiry",
+ ),
+ migrations.AddField(
+ model_name="connector",
+ name="active",
+ field=models.BooleanField(default=True),
+ ),
+ migrations.AddField(
+ model_name="connector",
+ 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,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0075_announcement.py b/bookwyrm/migrations/0075_announcement.py
new file mode 100644
index 00000000..b667c262
--- /dev/null
+++ b/bookwyrm/migrations/0075_announcement.py
@@ -0,0 +1,56 @@
+# Generated by Django 3.2 on 2021-05-20 19:34
+
+import bookwyrm.models.fields
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0074_auto_20210511_1829"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Announcement",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("updated_date", models.DateTimeField(auto_now=True)),
+ (
+ "remote_id",
+ bookwyrm.models.fields.RemoteIdField(
+ max_length=255,
+ null=True,
+ validators=[bookwyrm.models.fields.validate_remote_id],
+ ),
+ ),
+ ("preview", models.CharField(max_length=255)),
+ ("content", models.TextField(blank=True, null=True)),
+ ("event_date", models.DateTimeField(blank=True, null=True)),
+ ("start_date", models.DateTimeField(blank=True, null=True)),
+ ("end_date", models.DateTimeField(blank=True, null=True)),
+ ("active", models.BooleanField(default=True)),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py
index 0aef6385..6f378e83 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -1,4 +1,4 @@
-''' bring all the models into the app namespace '''
+""" bring all the models into the app namespace """
import inspect
import sys
@@ -9,26 +9,31 @@ from .connector import Connector
from .shelf import Shelf, ShelfBook
from .list import List, ListItem
-from .status import Status, GeneratedNote, Review, Comment, Quotation
+from .status import Status, GeneratedNote, Comment, Quotation
+from .status import Review, ReviewRating
from .status import Boost
from .attachment import Image
from .favorite import Favorite
from .notification import Notification
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
-from .tag import Tag, UserTag
-
from .user import User, KeyPair, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks
+from .report import Report, ReportComment
from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem
-from .site import SiteSettings, SiteInvite, PasswordReset
+from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
+from .announcement import Announcement
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
-activity_models = {c[1].activity_serializer.__name__: c[1] \
- for c in cls_members if hasattr(c[1], 'activity_serializer')}
+activity_models = {
+ c[1].activity_serializer.__name__: c[1]
+ for c in cls_members
+ if hasattr(c[1], "activity_serializer")
+}
status_models = [
- c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
+ c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)
+]
diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py
index 84293725..83b4c0ab 100644
--- a/bookwyrm/models/activitypub_mixin.py
+++ b/bookwyrm/models/activitypub_mixin.py
@@ -1,11 +1,13 @@
-''' activitypub model functionality '''
+""" activitypub model functionality """
from base64 import b64encode
+from collections import namedtuple
from functools import reduce
import json
import operator
import logging
from uuid import uuid4
import requests
+from requests.exceptions import HTTPError, SSLError
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
@@ -24,19 +26,29 @@ from bookwyrm.models.fields import ImageField, ManyToManyField
logger = logging.getLogger(__name__)
# I tried to separate these classes into mutliple files but I kept getting
# circular import errors so I gave up. I'm sure it could be done though!
+
+PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
+
+
+def set_activity_from_property_field(activity, obj, field):
+ """assign a model property value to the activity json"""
+ activity[field[1]] = getattr(obj, field[0])
+
+
class ActivitypubMixin:
- ''' add this mixin for models that are AP serializable '''
+ """add this mixin for models that are AP serializable"""
+
activity_serializer = lambda: {}
reverse_unfurl = False
def __init__(self, *args, **kwargs):
- ''' collect some info on model fields '''
+ """collect some info on model fields"""
self.image_fields = []
self.many_to_many_fields = []
- self.simple_fields = [] # "simple"
+ self.simple_fields = [] # "simple"
# sort model fields by type
for field in self._meta.get_fields():
- if not hasattr(field, 'field_to_activity'):
+ if not hasattr(field, "field_to_activity"):
continue
if isinstance(field, ImageField):
@@ -47,33 +59,47 @@ class ActivitypubMixin:
self.simple_fields.append(field)
# a list of allll the serializable fields
- self.activity_fields = self.image_fields + \
- self.many_to_many_fields + self.simple_fields
+ self.activity_fields = (
+ self.image_fields + self.many_to_many_fields + self.simple_fields
+ )
+ if hasattr(self, "property_fields"):
+ self.activity_fields += [
+ # pylint: disable=cell-var-from-loop
+ PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
+ for f in self.property_fields
+ ]
# these are separate to avoid infinite recursion issues
- self.deserialize_reverse_fields = self.deserialize_reverse_fields \
- if hasattr(self, 'deserialize_reverse_fields') else []
- self.serialize_reverse_fields = self.serialize_reverse_fields \
- if hasattr(self, 'serialize_reverse_fields') else []
+ self.deserialize_reverse_fields = (
+ self.deserialize_reverse_fields
+ if hasattr(self, "deserialize_reverse_fields")
+ else []
+ )
+ self.serialize_reverse_fields = (
+ self.serialize_reverse_fields
+ if hasattr(self, "serialize_reverse_fields")
+ else []
+ )
super().__init__(*args, **kwargs)
-
@classmethod
def find_existing_by_remote_id(cls, remote_id):
- ''' look up a remote id in the db '''
- return cls.find_existing({'id': remote_id})
+ """look up a remote id in the db"""
+ return cls.find_existing({"id": remote_id})
@classmethod
def find_existing(cls, data):
- ''' compare data to fields that can be used for deduplation.
+ """compare data to fields that can be used for deduplation.
This always includes remote_id, but can also be unique identifiers
- like an isbn for an edition '''
+ like an isbn for an edition"""
filters = []
# grabs all the data from the model to create django queryset filters
for field in cls._meta.get_fields():
- if not hasattr(field, 'deduplication_field') or \
- not field.deduplication_field:
+ if (
+ not hasattr(field, "deduplication_field")
+ or not field.deduplication_field
+ ):
continue
value = data.get(field.get_activitypub_field())
@@ -81,9 +107,9 @@ class ActivitypubMixin:
continue
filters.append({field.name: value})
- if hasattr(cls, 'origin_id') and 'id' in data:
+ if hasattr(cls, "origin_id") and "id" in data:
# kinda janky, but this handles special case for books
- filters.append({'origin_id': data['id']})
+ filters.append({"origin_id": data["id"]})
if not filters:
# if there are no deduplication fields, it will match the first
@@ -91,94 +117,100 @@ class ActivitypubMixin:
return None
objects = cls.objects
- if hasattr(objects, 'select_subclasses'):
+ if hasattr(objects, "select_subclasses"):
objects = objects.select_subclasses()
# an OR operation on all the match fields, sorry for the dense syntax
- match = objects.filter(
- reduce(operator.or_, (Q(**f) for f in filters))
- )
+ match = objects.filter(reduce(operator.or_, (Q(**f) for f in filters)))
# there OUGHT to be only one match
return match.first()
-
def broadcast(self, activity, sender, software=None):
- ''' send out an activity '''
+ """send out an activity"""
broadcast_task.delay(
sender.id,
json.dumps(activity, cls=activitypub.ActivityEncoder),
- self.get_recipients(software=software)
+ self.get_recipients(software=software),
)
-
def get_recipients(self, software=None):
- ''' figure out which inbox urls to post to '''
+ """figure out which inbox urls to post to"""
# first we have to figure out who should receive this activity
- privacy = self.privacy if hasattr(self, 'privacy') else 'public'
+ privacy = self.privacy if hasattr(self, "privacy") else "public"
# is this activity owned by a user (statuses, lists, shelves), or is it
# general to the instance (like books)
- user = self.user if hasattr(self, 'user') else None
- user_model = apps.get_model('bookwyrm.User', require_ready=True)
+ user = self.user if hasattr(self, "user") else None
+ user_model = apps.get_model("bookwyrm.User", require_ready=True)
if not user and isinstance(self, user_model):
# or maybe the thing itself is a user
user = self
# find anyone who's tagged in a status, for example
- mentions = self.recipients if hasattr(self, 'recipients') else []
+ mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes
- recipients = [u.inbox for u in mentions or []]
+ recipients = [u.inbox for u in mentions or [] if not u.local]
# unless it's a dm, all the followers should receive the activity
- if privacy != 'direct':
+ if privacy != "direct":
# we will send this out to a subset of all remote users
- queryset = user_model.objects.filter(
- local=False,
+ queryset = (
+ user_model.viewer_aware_objects(user)
+ .filter(
+ local=False,
+ )
+ .distinct()
)
# filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers
if software:
- queryset = queryset.filter(
- bookwyrm_user=(software == 'bookwyrm')
- )
+ queryset = queryset.filter(bookwyrm_user=(software == "bookwyrm"))
# if there's a user, we only want to send to the user's followers
if user:
queryset = queryset.filter(following=user)
# ideally, we will send to shared inboxes for efficiency
- shared_inboxes = queryset.filter(
- shared_inbox__isnull=False
- ).values_list('shared_inbox', flat=True).distinct()
+ shared_inboxes = (
+ queryset.filter(shared_inbox__isnull=False)
+ .values_list("shared_inbox", flat=True)
+ .distinct()
+ )
# but not everyone has a shared inbox
- inboxes = queryset.filter(
- shared_inbox__isnull=True
- ).values_list('inbox', flat=True)
+ inboxes = queryset.filter(shared_inbox__isnull=True).values_list(
+ "inbox", flat=True
+ )
recipients += list(shared_inboxes) + list(inboxes)
- return recipients
+ return list(set(recipients))
-
- def to_activity(self):
- ''' convert from a model to an activity '''
+ def to_activity_dataclass(self):
+ """convert from a model to an activity"""
activity = generate_activity(self)
- return self.activity_serializer(**activity).serialize()
+ return self.activity_serializer(**activity)
+
+ def to_activity(self, **kwargs): # pylint: disable=unused-argument
+ """convert from a model to a json activity"""
+ return self.to_activity_dataclass().serialize()
class ObjectMixin(ActivitypubMixin):
- ''' add this mixin for object models that are AP serializable '''
+ """add this mixin for object models that are AP serializable"""
+
def save(self, *args, created=None, **kwargs):
- ''' broadcast created/updated/deleted objects as appropriate '''
- broadcast = kwargs.get('broadcast', True)
- # this bonus kwarg woul cause an error in the base save method
- if 'broadcast' in kwargs:
- del kwargs['broadcast']
+ """broadcast created/updated/deleted objects as appropriate"""
+ broadcast = kwargs.get("broadcast", True)
+ # this bonus kwarg would cause an error in the base save method
+ if "broadcast" in kwargs:
+ del kwargs["broadcast"]
created = created or not bool(self.id)
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
- if not broadcast:
+ if not broadcast or (
+ hasattr(self, "status_type") and self.status_type == "Announce"
+ ):
return
# this will work for objects owned by a user (lists, shelves)
- user = self.user if hasattr(self, 'user') else None
+ user = self.user if hasattr(self, "user") else None
if created:
# broadcast Create activities for objects owned by a local user
@@ -187,15 +219,15 @@ class ObjectMixin(ActivitypubMixin):
try:
software = None
- # do we have a "pure" activitypub version of this for mastodon?
- if hasattr(self, 'pure_content'):
+ # do we have a "pure" activitypub version of this for mastodon?
+ if hasattr(self, "pure_content"):
pure_activity = self.to_create_activity(user, pure=True)
- self.broadcast(pure_activity, user, software='other')
- software = 'bookwyrm'
+ self.broadcast(pure_activity, user, software="other")
+ software = "bookwyrm"
# sends to BW only if we just did a pure version for masto
activity = self.to_create_activity(user)
self.broadcast(activity, user, software=software)
- except KeyError:
+ except AttributeError:
# janky as heck, this catches the mutliple inheritence chain
# for boosts and ignores this auxilliary broadcast
return
@@ -204,94 +236,91 @@ class ObjectMixin(ActivitypubMixin):
# --- updating an existing object
if not user:
# users don't have associated users, they ARE users
- user_model = apps.get_model('bookwyrm.User', require_ready=True)
+ user_model = apps.get_model("bookwyrm.User", require_ready=True)
if isinstance(self, user_model):
user = self
# book data tracks last editor
- elif hasattr(self, 'last_edited_by'):
+ elif hasattr(self, "last_edited_by"):
user = self.last_edited_by
# again, if we don't know the user or they're remote, don't bother
if not user or not user.local:
return
# is this a deletion?
- if hasattr(self, 'deleted') and self.deleted:
+ if hasattr(self, "deleted") and self.deleted:
activity = self.to_delete_activity(user)
else:
activity = self.to_update_activity(user)
self.broadcast(activity, user)
-
def to_create_activity(self, user, **kwargs):
- ''' returns the object wrapped in a Create activity '''
- activity_object = self.to_activity(**kwargs)
+ """returns the object wrapped in a Create activity"""
+ activity_object = self.to_activity_dataclass(**kwargs)
signature = None
- create_id = self.remote_id + '/activity'
- if 'content' in activity_object and activity_object['content']:
+ create_id = self.remote_id + "/activity"
+ if hasattr(activity_object, "content") and activity_object.content:
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
- content = activity_object['content']
- signed_message = signer.sign(SHA256.new(content.encode('utf8')))
+ content = activity_object.content
+ signed_message = signer.sign(SHA256.new(content.encode("utf8")))
signature = activitypub.Signature(
- creator='%s#main-key' % user.remote_id,
- created=activity_object['published'],
- signatureValue=b64encode(signed_message).decode('utf8')
+ creator="%s#main-key" % user.remote_id,
+ created=activity_object.published,
+ signatureValue=b64encode(signed_message).decode("utf8"),
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
- to=activity_object['to'],
- cc=activity_object['cc'],
+ to=activity_object.to,
+ cc=activity_object.cc,
object=activity_object,
signature=signature,
).serialize()
-
def to_delete_activity(self, user):
- ''' notice of deletion '''
+ """notice of deletion"""
return activitypub.Delete(
- id=self.remote_id + '/activity',
+ id=self.remote_id + "/activity",
actor=user.remote_id,
- to=['%s/followers' % user.remote_id],
- cc=['https://www.w3.org/ns/activitystreams#Public'],
- object=self.to_activity(),
+ to=["%s/followers" % user.remote_id],
+ cc=["https://www.w3.org/ns/activitystreams#Public"],
+ object=self,
).serialize()
-
def to_update_activity(self, user):
- ''' wrapper for Updates to an activity '''
- activity_id = '%s#update/%s' % (self.remote_id, uuid4())
+ """wrapper for Updates to an activity"""
+ activity_id = "%s#update/%s" % (self.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
- to=['https://www.w3.org/ns/activitystreams#Public'],
- object=self.to_activity()
+ to=["https://www.w3.org/ns/activitystreams#Public"],
+ object=self,
).serialize()
class OrderedCollectionPageMixin(ObjectMixin):
- ''' just the paginator utilities, so you don't HAVE to
- override ActivitypubMixin's to_activity (ie, for outbox) '''
+ """just the paginator utilities, so you don't HAVE to
+ override ActivitypubMixin's to_activity (ie, for outbox)"""
+
@property
def collection_remote_id(self):
- ''' this can be overriden if there's a special remote id, ie outbox '''
+ """this can be overriden if there's a special remote id, ie outbox"""
return self.remote_id
-
- def to_ordered_collection(self, queryset, \
- remote_id=None, page=False, collection_only=False, **kwargs):
- ''' an ordered collection of whatevers '''
+ def to_ordered_collection(
+ self, queryset, remote_id=None, page=False, collection_only=False, **kwargs
+ ):
+ """an ordered collection of whatevers"""
if not queryset.ordered:
- raise RuntimeError('queryset must be ordered')
+ raise RuntimeError("queryset must be ordered")
remote_id = remote_id or self.remote_id
if page:
- return to_ordered_collection_page(
- queryset, remote_id, **kwargs)
+ return to_ordered_collection_page(queryset, remote_id, **kwargs)
- if collection_only or not hasattr(self, 'activity_serializer'):
+ if collection_only or not hasattr(self, "activity_serializer"):
serializer = activitypub.OrderedCollection
activity = {}
else:
@@ -300,157 +329,188 @@ class OrderedCollectionPageMixin(ObjectMixin):
activity = generate_activity(self)
if remote_id:
- activity['id'] = remote_id
+ activity["id"] = remote_id
paginated = Paginator(queryset, PAGE_LENGTH)
# add computed fields specific to orderd collections
- activity['totalItems'] = paginated.count
- activity['first'] = '%s?page=1' % remote_id
- activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
+ activity["totalItems"] = paginated.count
+ activity["first"] = "%s?page=1" % remote_id
+ activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages)
- return serializer(**activity).serialize()
+ return serializer(**activity)
class OrderedCollectionMixin(OrderedCollectionPageMixin):
- ''' extends activitypub models to work as ordered collections '''
+ """extends activitypub models to work as ordered collections"""
+
@property
def collection_queryset(self):
- ''' usually an ordered collection model aggregates a different model '''
- raise NotImplementedError('Model must define collection_queryset')
+ """usually an ordered collection model aggregates a different model"""
+ raise NotImplementedError("Model must define collection_queryset")
activity_serializer = activitypub.OrderedCollection
- def to_activity(self, **kwargs):
- ''' an ordered collection of the specified model queryset '''
+ def to_activity_dataclass(self, **kwargs):
return self.to_ordered_collection(self.collection_queryset, **kwargs)
+ def to_activity(self, **kwargs):
+ """an ordered collection of the specified model queryset"""
+ return self.to_ordered_collection(
+ self.collection_queryset, **kwargs
+ ).serialize()
+
class CollectionItemMixin(ActivitypubMixin):
- ''' for items that are part of an (Ordered)Collection '''
- activity_serializer = activitypub.Add
- object_field = collection_field = None
+ """for items that are part of an (Ordered)Collection"""
+
+ activity_serializer = activitypub.CollectionItem
+
+ def broadcast(self, activity, sender, software="bookwyrm"):
+ """only send book collection updates to other bookwyrm instances"""
+ super().broadcast(activity, sender, software=software)
+
+ @property
+ def privacy(self):
+ """inherit the privacy of the list, or direct if pending"""
+ collection_field = getattr(self, self.collection_field)
+ if self.approved:
+ return collection_field.privacy
+ return "direct"
+
+ @property
+ def recipients(self):
+ """the owner of the list is a direct recipient"""
+ collection_field = getattr(self, self.collection_field)
+ if collection_field.user.local:
+ # don't broadcast to yourself
+ return []
+ return [collection_field.user]
def save(self, *args, broadcast=True, **kwargs):
- ''' broadcast updated '''
- created = not bool(self.id)
+ """broadcast updated"""
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
- # these shouldn't be edited, only created and deleted
- if not broadcast or not created or not self.user.local:
+ # list items can be updateda, normally you would only broadcast on created
+ if not broadcast or not self.user.local:
return
# adding an obj to the collection
- activity = self.to_add_activity()
+ activity = self.to_add_activity(self.user)
self.broadcast(activity, self.user)
-
- def delete(self, *args, **kwargs):
- ''' broadcast a remove activity '''
- activity = self.to_remove_activity()
+ def delete(self, *args, broadcast=True, **kwargs):
+ """broadcast a remove activity"""
+ activity = self.to_remove_activity(self.user)
super().delete(*args, **kwargs)
- self.broadcast(activity, self.user)
+ if self.user.local and broadcast:
+ self.broadcast(activity, self.user)
-
- def to_add_activity(self):
- ''' AP for shelving a book'''
- object_field = getattr(self, self.object_field)
+ def to_add_activity(self, user):
+ """AP for shelving a book"""
collection_field = getattr(self, self.collection_field)
return activitypub.Add(
- id='%s#add' % self.remote_id,
- actor=self.user.remote_id,
- object=object_field.to_activity(),
- target=collection_field.remote_id
+ id="{:s}#add".format(collection_field.remote_id),
+ actor=user.remote_id,
+ object=self.to_activity_dataclass(),
+ target=collection_field.remote_id,
).serialize()
- def to_remove_activity(self):
- ''' AP for un-shelving a book'''
- object_field = getattr(self, self.object_field)
+ def to_remove_activity(self, user):
+ """AP for un-shelving a book"""
collection_field = getattr(self, self.collection_field)
return activitypub.Remove(
- id='%s#remove' % self.remote_id,
- actor=self.user.remote_id,
- object=object_field.to_activity(),
- target=collection_field.remote_id
+ id="{:s}#remove".format(collection_field.remote_id),
+ actor=user.remote_id,
+ object=self.to_activity_dataclass(),
+ target=collection_field.remote_id,
).serialize()
class ActivityMixin(ActivitypubMixin):
- ''' add this mixin for models that are AP serializable '''
+ """add this mixin for models that are AP serializable"""
+
def save(self, *args, broadcast=True, **kwargs):
- ''' broadcast activity '''
+ """broadcast activity"""
super().save(*args, **kwargs)
- user = self.user if hasattr(self, 'user') else self.user_subject
+ user = self.user if hasattr(self, "user") else self.user_subject
if broadcast and user.local:
self.broadcast(self.to_activity(), user)
-
def delete(self, *args, broadcast=True, **kwargs):
- ''' nevermind, undo that activity '''
- user = self.user if hasattr(self, 'user') else self.user_subject
+ """nevermind, undo that activity"""
+ user = self.user if hasattr(self, "user") else self.user_subject
if broadcast and user.local:
self.broadcast(self.to_undo_activity(), user)
super().delete(*args, **kwargs)
-
def to_undo_activity(self):
- ''' undo an action '''
- user = self.user if hasattr(self, 'user') else self.user_subject
+ """undo an action"""
+ user = self.user if hasattr(self, "user") else self.user_subject
return activitypub.Undo(
- id='%s#undo' % self.remote_id,
+ id="%s#undo" % self.remote_id,
actor=user.remote_id,
- object=self.to_activity()
+ object=self,
).serialize()
def generate_activity(obj):
- ''' go through the fields on an object '''
+ """go through the fields on an object"""
activity = {}
for field in obj.activity_fields:
field.set_activity_from_field(activity, obj)
- if hasattr(obj, 'serialize_reverse_fields'):
+ if hasattr(obj, "serialize_reverse_fields"):
# for example, editions of a work
- for model_field_name, activity_field_name, sort_field in \
- obj.serialize_reverse_fields:
+ for (
+ model_field_name,
+ activity_field_name,
+ sort_field,
+ ) in obj.serialize_reverse_fields:
related_field = getattr(obj, model_field_name)
- activity[activity_field_name] = \
- unfurl_related_field(related_field, sort_field)
+ activity[activity_field_name] = unfurl_related_field(
+ related_field, sort_field=sort_field
+ )
- if not activity.get('id'):
- activity['id'] = obj.get_remote_id()
+ if not activity.get("id"):
+ activity["id"] = obj.get_remote_id()
return activity
def unfurl_related_field(related_field, sort_field=None):
- ''' load reverse lookups (like public key owner or Status attachment '''
- if hasattr(related_field, 'all'):
- return [unfurl_related_field(i) for i in related_field.order_by(
- sort_field).all()]
+ """load reverse lookups (like public key owner or Status attachment"""
+ if sort_field and hasattr(related_field, "all"):
+ return [
+ unfurl_related_field(i) for i in related_field.order_by(sort_field).all()
+ ]
if related_field.reverse_unfurl:
- return related_field.field_to_activity()
+ # if it's a one-to-one (key pair)
+ if hasattr(related_field, "field_to_activity"):
+ return related_field.field_to_activity()
+ # if it's one-to-many (attachments)
+ return related_field.to_activity()
return related_field.remote_id
@app.task
def broadcast_task(sender_id, activity, recipients):
- ''' the celery task for broadcast '''
- user_model = apps.get_model('bookwyrm.User', require_ready=True)
+ """the celery task for broadcast"""
+ user_model = apps.get_model("bookwyrm.User", require_ready=True)
sender = user_model.objects.get(id=sender_id)
for recipient in recipients:
try:
sign_and_send(sender, activity, recipient)
- except requests.exceptions.HTTPError as e:
- logger.exception(e)
+ except (HTTPError, SSLError, requests.exceptions.ConnectionError):
+ pass
def sign_and_send(sender, data, destination):
- ''' crpyto whatever and http junk '''
+ """crpyto whatever and http junk"""
now = http_date()
if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened.
- raise ValueError('No private key found for sender')
+ raise ValueError("No private key found for sender")
digest = make_digest(data)
@@ -458,11 +518,11 @@ def sign_and_send(sender, data, destination):
destination,
data=data,
headers={
- 'Date': now,
- 'Digest': digest,
- 'Signature': make_signature(sender, destination, now, digest),
- 'Content-Type': 'application/activity+json; charset=utf-8',
- 'User-Agent': USER_AGENT,
+ "Date": now,
+ "Digest": digest,
+ "Signature": make_signature(sender, destination, now, digest),
+ "Content-Type": "application/activity+json; charset=utf-8",
+ "User-Agent": USER_AGENT,
},
)
if not response.ok:
@@ -472,26 +532,26 @@ def sign_and_send(sender, data, destination):
# pylint: disable=unused-argument
def to_ordered_collection_page(
- queryset, remote_id, id_only=False, page=1, **kwargs):
- ''' serialize and pagiante a queryset '''
+ queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
+):
+ """serialize and pagiante a queryset"""
paginated = Paginator(queryset, PAGE_LENGTH)
- activity_page = paginated.page(page)
+ activity_page = paginated.get_page(page)
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:
- items = [s.to_activity() for s in activity_page.object_list]
+ items = [s.to_activity(pure=pure) for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():
- next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
+ next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number())
if activity_page.has_previous():
- prev_page = '%s?page=%d' % \
- (remote_id, activity_page.previous_page_number())
+ prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number())
return activitypub.OrderedCollectionPage(
- id='%s?page=%s' % (remote_id, page),
+ id="%s?page=%s" % (remote_id, page),
partOf=remote_id,
orderedItems=items,
next=next_page,
- prev=prev_page
- ).serialize()
+ prev=prev_page,
+ )
diff --git a/bookwyrm/models/announcement.py b/bookwyrm/models/announcement.py
new file mode 100644
index 00000000..498d5041
--- /dev/null
+++ b/bookwyrm/models/announcement.py
@@ -0,0 +1,28 @@
+""" admin announcements """
+from django.db import models
+from django.db.models import Q
+from django.utils import timezone
+
+from .base_model import BookWyrmModel
+
+
+class Announcement(BookWyrmModel):
+ """The admin has something to say"""
+
+ user = models.ForeignKey("User", on_delete=models.PROTECT)
+ preview = models.CharField(max_length=255)
+ content = models.TextField(null=True, blank=True)
+ event_date = models.DateTimeField(blank=True, null=True)
+ start_date = models.DateTimeField(blank=True, null=True)
+ end_date = models.DateTimeField(blank=True, null=True)
+ active = models.BooleanField(default=True)
+
+ @classmethod
+ def active_announcements(cls):
+ """announcements that should be displayed"""
+ now = timezone.now()
+ return cls.objects.filter(
+ Q(start_date__isnull=True) | Q(start_date__lte=now),
+ Q(end_date__isnull=True) | Q(end_date__gte=now),
+ active=True,
+ )
diff --git a/bookwyrm/models/attachment.py b/bookwyrm/models/attachment.py
index e3450a5a..c8b2e51c 100644
--- a/bookwyrm/models/attachment.py
+++ b/bookwyrm/models/attachment.py
@@ -1,4 +1,4 @@
-''' media that is posted in the app '''
+""" media that is posted in the app """
from django.db import models
from bookwyrm import activitypub
@@ -8,23 +8,29 @@ from . import fields
class Attachment(ActivitypubMixin, BookWyrmModel):
- ''' an image (or, in the future, video etc) associated with a status '''
+ """an image (or, in the future, video etc) associated with a status"""
+
status = models.ForeignKey(
- 'Status',
- on_delete=models.CASCADE,
- related_name='attachments',
- null=True
+ "Status", on_delete=models.CASCADE, related_name="attachments", null=True
)
reverse_unfurl = True
+
class Meta:
- ''' one day we'll have other types of attachments besides images '''
+ """one day we'll have other types of attachments besides images"""
+
abstract = True
class Image(Attachment):
- ''' an image attachment '''
- image = fields.ImageField(
- upload_to='status/', null=True, blank=True, activitypub_field='url')
- caption = fields.TextField(null=True, blank=True, activitypub_field='name')
+ """an image attachment"""
- activity_serializer = activitypub.Image
+ image = fields.ImageField(
+ upload_to="status/",
+ null=True,
+ blank=True,
+ activitypub_field="url",
+ alt_field="caption",
+ )
+ caption = fields.TextField(null=True, blank=True, activitypub_field="name")
+
+ activity_serializer = activitypub.Document
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index d0cb8d19..c4e26c5a 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -1,4 +1,4 @@
-''' database schema for info about authors '''
+""" database schema for info about authors """
from django.db import models
from bookwyrm import activitypub
@@ -9,9 +9,20 @@ from . import fields
class Author(BookDataModel):
- ''' basic biographic info '''
+ """basic biographic info"""
+
wikipedia_link = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
+ isni = fields.CharField(
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
+ viaf_id = fields.CharField(
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
+ gutenberg_id = fields.CharField(
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
# idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)
@@ -22,7 +33,7 @@ class Author(BookDataModel):
bio = fields.HtmlField(null=True, blank=True)
def get_remote_id(self):
- ''' editions and works both use "book" instead of model_name '''
- return 'https://%s/author/%s' % (DOMAIN, self.id)
+ """editions and works both use "book" instead of model_name"""
+ return "https://%s/author/%s" % (DOMAIN, self.id)
activity_serializer = activitypub.Author
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index 7af48749..2cb7c036 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -1,4 +1,4 @@
-''' base model with default fields '''
+""" base model with default fields """
from django.db import models
from django.dispatch import receiver
@@ -6,35 +6,77 @@ from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
+DeactivationReason = models.TextChoices(
+ "DeactivationReason",
+ [
+ "self_deletion",
+ "moderator_deletion",
+ "domain_block",
+ ],
+)
+
+
class BookWyrmModel(models.Model):
- ''' shared fields '''
+ """shared fields"""
+
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
- remote_id = RemoteIdField(null=True, activitypub_field='id')
+ remote_id = RemoteIdField(null=True, activitypub_field="id")
def get_remote_id(self):
- ''' generate a url that resolves to the local object '''
- base_path = 'https://%s' % DOMAIN
- if hasattr(self, 'user'):
- base_path = '%s%s' % (base_path, self.user.local_path)
+ """generate a url that resolves to the local object"""
+ base_path = "https://%s" % DOMAIN
+ if hasattr(self, "user"):
+ base_path = "%s%s" % (base_path, self.user.local_path)
model_name = type(self).__name__.lower()
- return '%s/%s/%d' % (base_path, model_name, self.id)
+ return "%s/%s/%d" % (base_path, model_name, self.id)
class Meta:
- ''' this is just here to provide default fields for other models '''
+ """this is just here to provide default fields for other models"""
+
abstract = True
@property
def local_path(self):
- ''' how to link to this object in the local app '''
- return self.get_remote_id().replace('https://%s' % DOMAIN, '')
+ """how to link to this object in the local app"""
+ 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)
-#pylint: disable=unused-argument
-def execute_after_save(sender, instance, created, *args, **kwargs):
- ''' set the remote_id after save (when the id is available) '''
- if not created or not hasattr(instance, 'get_remote_id'):
+# pylint: disable=unused-argument
+def set_remote_id(sender, instance, created, *args, **kwargs):
+ """set the remote_id after save (when the id is available)"""
+ if not created or not hasattr(instance, "get_remote_id"):
return
if not instance.remote_id:
instance.remote_id = instance.get_remote_id()
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index f1f20830..869ff04d 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -1,35 +1,50 @@
-''' database schema for books and shelves '''
+""" database schema for books and shelves """
import re
from django.db import models
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
+from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
from . import fields
+
class BookDataModel(ObjectMixin, BookWyrmModel):
- ''' fields shared between editable book data (books, works, authors) '''
+ """fields shared between editable book data (books, works, authors)"""
+
origin_id = models.CharField(max_length=255, null=True, blank=True)
openlibrary_key = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
+ inventaire_id = fields.CharField(
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
librarything_key = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
goodreads_key = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
+ bnf_id = fields.CharField( # Bibliothèque nationale de France
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
- last_edited_by = models.ForeignKey(
- 'User', on_delete=models.PROTECT, null=True)
+ last_edited_by = fields.ForeignKey(
+ "User",
+ on_delete=models.PROTECT,
+ null=True,
+ )
class Meta:
- ''' can't initialize this model, that wouldn't make sense '''
+ """can't initialize this model, that wouldn't make sense"""
+
abstract = True
def save(self, *args, **kwargs):
- ''' ensure that the remote_id is within this instance '''
+ """ensure that the remote_id is within this instance"""
if self.id:
self.remote_id = self.get_remote_id()
else:
@@ -37,21 +52,25 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
self.remote_id = None
return super().save(*args, **kwargs)
+ def broadcast(self, activity, sender, software="bookwyrm"):
+ """only send book data updates to other bookwyrm instances"""
+ super().broadcast(activity, sender, software=software)
+
class Book(BookDataModel):
- ''' a generic book, which can mean either an edition or a work '''
- connector = models.ForeignKey(
- 'Connector', on_delete=models.PROTECT, null=True)
+ """a generic book, which can mean either an edition or a work"""
+
+ connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
# book/work metadata
- title = fields.CharField(max_length=255)
+ title = fields.TextField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True)
- subtitle = fields.CharField(max_length=255, blank=True, null=True)
+ subtitle = fields.TextField(max_length=255, blank=True, null=True)
description = fields.HtmlField(blank=True, null=True)
languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
- series = fields.CharField(max_length=255, blank=True, null=True)
+ series = fields.TextField(max_length=255, blank=True, null=True)
series_number = fields.CharField(max_length=255, blank=True, null=True)
subjects = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
@@ -59,9 +78,10 @@ class Book(BookDataModel):
subject_places = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
)
- authors = fields.ManyToManyField('Author')
+ authors = fields.ManyToManyField("Author")
cover = fields.ImageField(
- upload_to='covers/', blank=True, null=True, alt_field='alt_text')
+ upload_to="covers/", blank=True, null=True, alt_field="alt_text"
+ )
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
@@ -69,42 +89,44 @@ class Book(BookDataModel):
@property
def author_text(self):
- ''' format a list of authors '''
- return ', '.join(a.name for a in self.authors.all())
+ """format a list of authors"""
+ return ", ".join(a.name for a in self.authors.all())
@property
def latest_readthrough(self):
- ''' most recent readthrough activity '''
- return self.readthrough_set.order_by('-updated_date').first()
+ """most recent readthrough activity"""
+ return self.readthrough_set.order_by("-updated_date").first()
@property
def edition_info(self):
- ''' properties of this edition, as a string '''
+ """properties of this edition, as a string"""
items = [
- self.physical_format if hasattr(self, 'physical_format') else None,
- self.languages[0] + ' language' if self.languages and \
- self.languages[0] != 'English' else None,
+ self.physical_format if hasattr(self, "physical_format") else None,
+ self.languages[0] + " language"
+ if self.languages and self.languages[0] != "English"
+ else None,
str(self.published_date.year) if self.published_date else None,
+ ", ".join(self.publishers) if hasattr(self, "publishers") else None,
]
- return ', '.join(i for i in items if i)
+ return ", ".join(i for i in items if i)
@property
def alt_text(self):
- ''' image alt test '''
- text = '%s cover' % self.title
+ """image alt test"""
+ text = "%s" % self.title
if self.edition_info:
- text += ' (%s)' % self.edition_info
+ text += " (%s)" % self.edition_info
return text
def save(self, *args, **kwargs):
- ''' can't be abstract for query reasons, but you shouldn't USE it '''
+ """can't be abstract for query reasons, but you shouldn't USE it"""
if not isinstance(self, Edition) and not isinstance(self, Work):
- raise ValueError('Books should be added as Editions or Works')
+ raise ValueError("Books should be added as Editions or Works")
return super().save(*args, **kwargs)
def get_remote_id(self):
- ''' editions and works both use "book" instead of model_name '''
- return 'https://%s/book/%d' % (DOMAIN, self.id)
+ """editions and works both use "book" instead of model_name"""
+ return "https://%s/book/%d" % (DOMAIN, self.id)
def __repr__(self):
return "<{} key={!r} title={!r}>".format(
@@ -115,81 +137,91 @@ class Book(BookDataModel):
class Work(OrderedCollectionPageMixin, Book):
- ''' a work (an abstract concept of a book that manifests in an edition) '''
+ """a work (an abstract concept of a book that manifests in an edition)"""
+
# library of congress catalog control number
lccn = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
- # this has to be nullable but should never be null
- default_edition = fields.ForeignKey(
- 'Edition',
- on_delete=models.PROTECT,
- null=True,
- load_remote=False
+ max_length=255, blank=True, null=True, deduplication_field=True
)
def save(self, *args, **kwargs):
- ''' set some fields on the edition object '''
+ """set some fields on the edition object"""
# set rank
for edition in self.editions.all():
edition.save()
return super().save(*args, **kwargs)
- def get_default_edition(self):
- ''' in case the default edition is not set '''
- return self.default_edition or self.editions.order_by(
- '-edition_rank'
- ).first()
+ @property
+ def default_edition(self):
+ """in case the default edition is not set"""
+ return self.editions.order_by("-edition_rank").first()
def to_edition_list(self, **kwargs):
- ''' an ordered collection of editions '''
+ """an ordered collection of editions"""
return self.to_ordered_collection(
- self.editions.order_by('-edition_rank').all(),
- remote_id='%s/editions' % self.remote_id,
- **kwargs
+ self.editions.order_by("-edition_rank").all(),
+ remote_id="%s/editions" % self.remote_id,
+ **kwargs,
)
activity_serializer = activitypub.Work
- serialize_reverse_fields = [('editions', 'editions', '-edition_rank')]
- deserialize_reverse_fields = [('editions', 'editions')]
+ serialize_reverse_fields = [("editions", "editions", "-edition_rank")]
+ deserialize_reverse_fields = [("editions", "editions")]
class Edition(Book):
- ''' an edition of a book '''
+ """an edition of a book"""
+
# these identifiers only apply to editions, not works
isbn_10 = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
isbn_13 = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
oclc_number = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
asin = fields.CharField(
- max_length=255, blank=True, null=True, deduplication_field=True)
+ max_length=255, blank=True, null=True, deduplication_field=True
+ )
pages = fields.IntegerField(blank=True, null=True)
physical_format = fields.CharField(max_length=255, blank=True, null=True)
publishers = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
shelves = models.ManyToManyField(
- 'Shelf',
+ "Shelf",
symmetrical=False,
- through='ShelfBook',
- through_fields=('book', 'shelf')
+ through="ShelfBook",
+ through_fields=("book", "shelf"),
)
parent_work = fields.ForeignKey(
- 'Work', on_delete=models.PROTECT, null=True,
- related_name='editions', activitypub_field='work')
+ "Work",
+ on_delete=models.PROTECT,
+ null=True,
+ related_name="editions",
+ activitypub_field="work",
+ )
edition_rank = fields.IntegerField(default=0)
activity_serializer = activitypub.Edition
- name_field = 'title'
+ name_field = "title"
def get_rank(self):
- ''' calculate how complete the data is on this edition '''
- if self.parent_work and self.parent_work.default_edition == self:
- # default edition has the highest rank
- return 20
+ """calculate how complete the data is on this edition"""
rank = 0
+ # big ups for havinga cover
rank += int(bool(self.cover)) * 3
+ # is it in the instance's preferred language?
+ rank += int(bool(DEFAULT_LANGUAGE in self.languages))
+ # prefer print editions
+ if self.physical_format:
+ rank += int(
+ bool(self.physical_format.lower() in ["paperback", "hardcover"])
+ )
+
+ # does it have metadata?
rank += int(bool(self.isbn_13))
rank += int(bool(self.isbn_10))
rank += int(bool(self.oclc_number))
@@ -200,13 +232,19 @@ class Edition(Book):
return rank
def save(self, *args, **kwargs):
- ''' set some fields on the edition object '''
+ """set some fields on the edition object"""
# calculate isbn 10/13
- if self.isbn_13 and self.isbn_13[:3] == '978' and not self.isbn_10:
+ if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10:
self.isbn_10 = isbn_13_to_10(self.isbn_13)
if self.isbn_10 and not self.isbn_13:
self.isbn_13 = isbn_10_to_13(self.isbn_10)
+ # normalize isbn format
+ if self.isbn_10:
+ self.isbn_10 = re.sub(r"[^0-9X]", "", self.isbn_10)
+ if self.isbn_13:
+ self.isbn_13 = re.sub(r"[^0-9X]", "", self.isbn_13)
+
# set rank
self.edition_rank = self.get_rank()
@@ -214,17 +252,18 @@ class Edition(Book):
def isbn_10_to_13(isbn_10):
- ''' convert an isbn 10 into an isbn 13 '''
- isbn_10 = re.sub(r'[^0-9X]', '', isbn_10)
+ """convert an isbn 10 into an isbn 13"""
+ isbn_10 = re.sub(r"[^0-9X]", "", isbn_10)
# drop the last character of the isbn 10 number (the original checkdigit)
converted = isbn_10[:9]
# add "978" to the front
- converted = '978' + converted
+ converted = "978" + converted
# add a check digit to the end
# multiply the odd digits by 1 and the even digits by 3 and sum them
try:
- checksum = sum(int(i) for i in converted[::2]) + \
- sum(int(i) * 3 for i in converted[1::2])
+ checksum = sum(int(i) for i in converted[::2]) + sum(
+ int(i) * 3 for i in converted[1::2]
+ )
except ValueError:
return None
# add the checksum mod 10 to the end
@@ -235,11 +274,11 @@ def isbn_10_to_13(isbn_10):
def isbn_13_to_10(isbn_13):
- ''' convert isbn 13 to 10, if possible '''
- if isbn_13[:3] != '978':
+ """convert isbn 13 to 10, if possible"""
+ if isbn_13[:3] != "978":
return None
- isbn_13 = re.sub(r'[^0-9X]', '', isbn_13)
+ isbn_13 = re.sub(r"[^0-9X]", "", isbn_13)
# remove '978' and old checkdigit
converted = isbn_13[3:-1]
@@ -252,5 +291,5 @@ def isbn_13_to_10(isbn_13):
checkdigit = checksum % 11
checkdigit = 11 - checkdigit
if checkdigit == 10:
- checkdigit = 'X'
+ checkdigit = "X"
return converted + str(checkdigit)
diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py
index 6f64cdf3..2d671790 100644
--- a/bookwyrm/models/connector.py
+++ b/bookwyrm/models/connector.py
@@ -1,43 +1,32 @@
-''' manages interfaces with external sources of book data '''
+""" manages interfaces with external sources of book data """
from django.db import models
from bookwyrm.connectors.settings import CONNECTORS
-from .base_model import BookWyrmModel
+from .base_model import BookWyrmModel, DeactivationReason
+
+
+ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
-ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
class Connector(BookWyrmModel):
- ''' book data source connectors '''
+ """book data source connectors"""
+
identifier = models.CharField(max_length=255, unique=True)
priority = models.IntegerField(default=2)
name = models.CharField(max_length=255, null=True, blank=True)
local = models.BooleanField(default=False)
- connector_file = models.CharField(
- max_length=255,
- choices=ConnectorFiles.choices
- )
+ connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)
api_key = models.CharField(max_length=255, null=True, blank=True)
+ active = models.BooleanField(default=True)
+ deactivation_reason = models.CharField(
+ max_length=255, choices=DeactivationReason.choices, null=True, blank=True
+ )
base_url = models.CharField(max_length=255)
books_url = models.CharField(max_length=255)
covers_url = models.CharField(max_length=255)
search_url = models.CharField(max_length=255, null=True, blank=True)
-
- politeness_delay = models.IntegerField(null=True, blank=True) #seconds
- max_query_count = models.IntegerField(null=True, blank=True)
- # how many queries executed in a unit of time, like a day
- query_count = models.IntegerField(default=0)
- # when to reset the query count back to 0 (ie, after 1 day)
- query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True)
-
- class Meta:
- ''' check that there's code to actually use this connector '''
- constraints = [
- models.CheckConstraint(
- check=models.Q(connector_file__in=ConnectorFiles),
- name='connector_file_valid'
- )
- ]
+ isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
def __str__(self):
return "{} ({})".format(
diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py
index f9019501..c4518119 100644
--- a/bookwyrm/models/favorite.py
+++ b/bookwyrm/models/favorite.py
@@ -1,4 +1,4 @@
-''' like/fav/star a status '''
+""" like/fav/star a status """
from django.apps import apps
from django.db import models
from django.utils import timezone
@@ -7,46 +7,61 @@ from bookwyrm import activitypub
from .activitypub_mixin import ActivityMixin
from .base_model import BookWyrmModel
from . import fields
+from .status import Status
+
class Favorite(ActivityMixin, BookWyrmModel):
- ''' fav'ing a post '''
+ """fav'ing a post"""
+
user = fields.ForeignKey(
- 'User', on_delete=models.PROTECT, activitypub_field='actor')
+ "User", on_delete=models.PROTECT, activitypub_field="actor"
+ )
status = fields.ForeignKey(
- 'Status', on_delete=models.PROTECT, activitypub_field='object')
+ "Status", on_delete=models.PROTECT, activitypub_field="object"
+ )
activity_serializer = activitypub.Like
+ @classmethod
+ def ignore_activity(cls, activity):
+ """don't bother with incoming favs of unknown statuses"""
+ return not Status.objects.filter(remote_id=activity.object).exists()
+
def save(self, *args, **kwargs):
- ''' update user active time '''
+ """update user active time"""
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
if self.status.user.local and self.status.user != self.user:
notification_model = apps.get_model(
- 'bookwyrm.Notification', require_ready=True)
+ "bookwyrm.Notification", require_ready=True
+ )
notification_model.objects.create(
user=self.status.user,
- notification_type='FAVORITE',
+ notification_type="FAVORITE",
related_user=self.user,
- related_status=self.status
+ related_status=self.status,
)
def delete(self, *args, **kwargs):
- ''' delete and delete notifications '''
+ """delete and delete notifications"""
# check for notification
if self.status.user.local:
notification_model = apps.get_model(
- 'bookwyrm.Notification', require_ready=True)
+ "bookwyrm.Notification", require_ready=True
+ )
notification = notification_model.objects.filter(
- user=self.status.user, related_user=self.user,
- related_status=self.status, notification_type='FAVORITE'
+ user=self.status.user,
+ related_user=self.user,
+ related_status=self.status,
+ notification_type="FAVORITE",
).first()
if notification:
notification.delete()
super().delete(*args, **kwargs)
class Meta:
- ''' can't fav things twice '''
- unique_together = ('user', 'status')
+ """can't fav things twice"""
+
+ unique_together = ("user", "status")
diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py
index 953cd9c8..e297c46c 100644
--- a/bookwyrm/models/federated_server.py
+++ b/bookwyrm/models/federated_server.py
@@ -1,15 +1,68 @@
-''' connections to external ActivityPub servers '''
+""" connections to external ActivityPub servers """
+from urllib.parse import urlparse
+from django.apps import apps
from django.db import models
from .base_model import BookWyrmModel
+FederationStatus = models.TextChoices(
+ "Status",
+ [
+ "federated",
+ "blocked",
+ ],
+)
+
class FederatedServer(BookWyrmModel):
- ''' store which server's we federate with '''
- server_name = models.CharField(max_length=255, unique=True)
- # federated, blocked, whatever else
- status = models.CharField(max_length=255, default='federated')
- # is it mastodon, bookwyrm, etc
- application_type = models.CharField(max_length=255, null=True)
- application_version = models.CharField(max_length=255, null=True)
+ """store which servers we federate with"""
-# TODO: blocked servers
+ server_name = models.CharField(max_length=255, unique=True)
+ status = models.CharField(
+ max_length=255, default="federated", choices=FederationStatus.choices
+ )
+ # is it mastodon, bookwyrm, etc
+ application_type = models.CharField(max_length=255, null=True, blank=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()
+
+ # deactivate all associated users
+ self.user_set.filter(is_active=True).update(
+ is_active=False, deactivation_reason="domain_block"
+ )
+
+ # check for related connectors
+ if self.application_type == "bookwyrm":
+ connector_model = apps.get_model("bookwyrm.Connector", require_ready=True)
+ connector_model.objects.filter(
+ identifier=self.server_name, active=True
+ ).update(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
+ )
+
+ # check for related connectors
+ if self.application_type == "bookwyrm":
+ connector_model = apps.get_model("bookwyrm.Connector", require_ready=True)
+ connector_model.objects.filter(
+ identifier=self.server_name,
+ active=False,
+ deactivation_reason="domain_block",
+ ).update(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()
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index 55de1fab..379323b9 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -1,5 +1,6 @@
-''' activitypub-aware django model fields '''
+""" activitypub-aware django model fields """
from dataclasses import MISSING
+import imghdr
import re
from uuid import uuid4
@@ -9,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
+from django.forms import ClearableFileInput, ImageField as DjangoImageField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
@@ -18,37 +20,43 @@ from bookwyrm.settings import DOMAIN
def validate_remote_id(value):
- ''' make sure the remote_id looks like a url '''
- if not value or not re.match(r'^http.?:\/\/[^\s]+$', value):
+ """make sure the remote_id looks like a url"""
+ if not value or not re.match(r"^http.?:\/\/[^\s]+$", value):
raise ValidationError(
- _('%(value)s is not a valid remote_id'),
- params={'value': value},
+ _("%(value)s is not a valid remote_id"),
+ params={"value": value},
)
def validate_localname(value):
- ''' make sure localnames look okay '''
- if not re.match(r'^[A-Za-z\-_\.0-9]+$', value):
+ """make sure localnames look okay"""
+ if not re.match(r"^[A-Za-z\-_\.0-9]+$", value):
raise ValidationError(
- _('%(value)s is not a valid username'),
- params={'value': value},
+ _("%(value)s is not a valid username"),
+ params={"value": value},
)
def validate_username(value):
- ''' make sure usernames look okay '''
- if not re.match(r'^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$', value):
+ """make sure usernames look okay"""
+ if not re.match(r"^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$", value):
raise ValidationError(
- _('%(value)s is not a valid username'),
- params={'value': value},
+ _("%(value)s is not a valid username"),
+ params={"value": value},
)
class ActivitypubFieldMixin:
- ''' make a database field serializable '''
- def __init__(self, *args, \
- activitypub_field=None, activitypub_wrapper=None,
- deduplication_field=False, **kwargs):
+ """make a database field serializable"""
+
+ def __init__(
+ self,
+ *args,
+ activitypub_field=None,
+ activitypub_wrapper=None,
+ deduplication_field=False,
+ **kwargs
+ ):
self.deduplication_field = deduplication_field
if activitypub_wrapper:
self.activitypub_wrapper = activitypub_field
@@ -57,24 +65,22 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field
super().__init__(*args, **kwargs)
-
def set_field_from_activity(self, instance, data):
- ''' helper function for assinging a value to the field '''
+ """helper function for assinging a value to the field"""
try:
value = getattr(data, self.get_activitypub_field())
except AttributeError:
# masssively hack-y workaround for boosts
- if self.get_activitypub_field() != 'attributedTo':
+ if self.get_activitypub_field() != "attributedTo":
raise
- value = getattr(data, 'actor')
+ value = getattr(data, "actor")
formatted = self.field_from_activity(value)
- if formatted is None or formatted is MISSING:
+ if formatted is None or formatted is MISSING or formatted == {}:
return
setattr(instance, self.name, formatted)
-
def set_activity_from_field(self, activity, instance):
- ''' update the json object '''
+ """update the json object"""
value = getattr(instance, self.name)
formatted = self.field_to_activity(value)
if formatted is None:
@@ -82,37 +88,37 @@ class ActivitypubFieldMixin:
key = self.get_activitypub_field()
# TODO: surely there's a better way
- if instance.__class__.__name__ == 'Boost' and key == 'attributedTo':
- key = 'actor'
+ if instance.__class__.__name__ == "Boost" and key == "attributedTo":
+ key = "actor"
if isinstance(activity.get(key), list):
activity[key] += formatted
else:
activity[key] = formatted
-
def field_to_activity(self, value):
- ''' formatter to convert a model value into activitypub '''
- if hasattr(self, 'activitypub_wrapper'):
+ """formatter to convert a model value into activitypub"""
+ if hasattr(self, "activitypub_wrapper"):
return {self.activitypub_wrapper: value}
return value
def field_from_activity(self, value):
- ''' formatter to convert activitypub into a model value '''
- if hasattr(self, 'activitypub_wrapper'):
+ """formatter to convert activitypub into a model value"""
+ if value and hasattr(self, "activitypub_wrapper"):
value = value.get(self.activitypub_wrapper)
return value
def get_activitypub_field(self):
- ''' model_field_name to activitypubFieldName '''
+ """model_field_name to activitypubFieldName"""
if self.activitypub_field:
return self.activitypub_field
- name = self.name.split('.')[-1]
- components = name.split('_')
- return components[0] + ''.join(x.title() for x in components[1:])
+ name = self.name.split(".")[-1]
+ components = name.split("_")
+ return components[0] + "".join(x.title() for x in components[1:])
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
- ''' default (de)serialization for foreign key and one to one '''
+ """default (de)serialization for foreign key and one to one"""
+
def __init__(self, *args, load_remote=True, **kwargs):
self.load_remote = load_remote
super().__init__(*args, **kwargs)
@@ -122,13 +128,12 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
return None
related_model = self.related_model
- if isinstance(value, dict) and value.get('id'):
+ if hasattr(value, "id") and value.id:
if not self.load_remote:
# only look in the local database
- return related_model.find_existing(value)
+ return related_model.find_existing(value.serialize())
# this is an activitypub object, which we can deserialize
- activity_serializer = related_model.activity_serializer
- return activity_serializer(**value).to_model(related_model)
+ return value.to_model(model=related_model)
try:
# make sure the value looks like a remote id
validate_remote_id(value)
@@ -139,103 +144,102 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
if not self.load_remote:
# only look in the local database
return related_model.find_existing_by_remote_id(value)
- return activitypub.resolve_remote_id(related_model, value)
+ return activitypub.resolve_remote_id(value, model=related_model)
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
- ''' a url that serves as a unique identifier '''
+ """a url that serves as a unique identifier"""
+
def __init__(self, *args, max_length=255, validators=None, **kwargs):
validators = validators or [validate_remote_id]
- super().__init__(
- *args, max_length=max_length, validators=validators,
- **kwargs
- )
+ super().__init__(*args, max_length=max_length, validators=validators, **kwargs)
# for this field, the default is true. false everywhere else.
- self.deduplication_field = kwargs.get('deduplication_field', True)
+ self.deduplication_field = kwargs.get("deduplication_field", True)
class UsernameField(ActivitypubFieldMixin, models.CharField):
- ''' activitypub-aware username field '''
- def __init__(self, activitypub_field='preferredUsername', **kwargs):
+ """activitypub-aware username field"""
+
+ def __init__(self, activitypub_field="preferredUsername", **kwargs):
self.activitypub_field = activitypub_field
# I don't totally know why pylint is mad at this, but it makes it work
- super( #pylint: disable=bad-super-call
- ActivitypubFieldMixin, self
- ).__init__(
- _('username'),
+ super(ActivitypubFieldMixin, self).__init__( # pylint: disable=bad-super-call
+ _("username"),
max_length=150,
unique=True,
validators=[validate_username],
error_messages={
- 'unique': _('A user with that username already exists.'),
+ "unique": _("A user with that username already exists."),
},
)
def deconstruct(self):
- ''' implementation of models.Field deconstruct '''
+ """implementation of models.Field deconstruct"""
name, path, args, kwargs = super().deconstruct()
- del kwargs['verbose_name']
- del kwargs['max_length']
- del kwargs['unique']
- del kwargs['validators']
- del kwargs['error_messages']
+ del kwargs["verbose_name"]
+ del kwargs["max_length"]
+ del kwargs["unique"]
+ del kwargs["validators"]
+ del kwargs["error_messages"]
return name, path, args, kwargs
def field_to_activity(self, value):
- return value.split('@')[0]
+ return value.split("@")[0]
-PrivacyLevels = models.TextChoices('Privacy', [
- 'public',
- 'unlisted',
- 'followers',
- 'direct'
-])
+PrivacyLevels = models.TextChoices(
+ "Privacy", ["public", "unlisted", "followers", "direct"]
+)
+
class PrivacyField(ActivitypubFieldMixin, models.CharField):
- ''' this maps to two differente activitypub fields '''
- public = 'https://www.w3.org/ns/activitystreams#Public'
+ """this maps to two differente activitypub fields"""
+
+ public = "https://www.w3.org/ns/activitystreams#Public"
+
def __init__(self, *args, **kwargs):
super().__init__(
- *args, max_length=255,
- choices=PrivacyLevels.choices, default='public')
+ *args, max_length=255, choices=PrivacyLevels.choices, default="public"
+ )
def set_field_from_activity(self, instance, data):
to = data.to
cc = data.cc
if to == [self.public]:
- setattr(instance, self.name, 'public')
+ setattr(instance, self.name, "public")
elif cc == []:
- setattr(instance, self.name, 'direct')
+ setattr(instance, self.name, "direct")
elif self.public in cc:
- setattr(instance, self.name, 'unlisted')
+ setattr(instance, self.name, "unlisted")
else:
- setattr(instance, self.name, 'followers')
+ setattr(instance, self.name, "followers")
def set_activity_from_field(self, activity, instance):
# explicitly to anyone mentioned (statuses only)
mentions = []
- if hasattr(instance, 'mention_users'):
+ if hasattr(instance, "mention_users"):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
- followers = instance.user.__class__._meta.get_field('followers')\
- .field_to_activity(instance.user.followers)
- if instance.privacy == 'public':
- activity['to'] = [self.public]
- activity['cc'] = [followers] + mentions
- elif instance.privacy == 'unlisted':
- activity['to'] = [followers]
- activity['cc'] = [self.public] + mentions
- elif instance.privacy == 'followers':
- activity['to'] = [followers]
- activity['cc'] = mentions
- if instance.privacy == 'direct':
- activity['to'] = mentions
- activity['cc'] = []
+ followers = instance.user.__class__._meta.get_field(
+ "followers"
+ ).field_to_activity(instance.user.followers)
+ if instance.privacy == "public":
+ activity["to"] = [self.public]
+ activity["cc"] = [followers] + mentions
+ elif instance.privacy == "unlisted":
+ activity["to"] = [followers]
+ activity["cc"] = [self.public] + mentions
+ elif instance.privacy == "followers":
+ activity["to"] = [followers]
+ activity["cc"] = mentions
+ if instance.privacy == "direct":
+ activity["to"] = mentions
+ activity["cc"] = []
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
- ''' activitypub-aware foreign key field '''
+ """activitypub-aware foreign key field"""
+
def field_to_activity(self, value):
if not value:
return None
@@ -243,7 +247,8 @@ class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
- ''' activitypub-aware foreign key field '''
+ """activitypub-aware foreign key field"""
+
def field_to_activity(self, value):
if not value:
return None
@@ -251,13 +256,14 @@ class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
- ''' activitypub-aware many to many field '''
+ """activitypub-aware many to many field"""
+
def __init__(self, *args, link_only=False, **kwargs):
self.link_only = link_only
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
- ''' helper function for assinging a value to the field '''
+ """helper function for assinging a value to the field"""
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
@@ -267,41 +273,47 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
def field_to_activity(self, value):
if self.link_only:
- return '%s/%s' % (value.instance.remote_id, self.name)
+ return "%s/%s" % (value.instance.remote_id, self.name)
return [i.remote_id for i in value.all()]
def field_from_activity(self, value):
- items = []
if value is None or value is MISSING:
- return []
+ return None
+ if not isinstance(value, list):
+ # If this is a link, we currently aren't doing anything with it
+ return None
+ items = []
for remote_id in value:
try:
validate_remote_id(remote_id)
except ValidationError:
continue
items.append(
- activitypub.resolve_remote_id(self.related_model, remote_id)
+ activitypub.resolve_remote_id(remote_id, model=self.related_model)
)
return items
class TagField(ManyToManyField):
- ''' special case of many to many that uses Tags '''
+ """special case of many to many that uses Tags"""
+
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.activitypub_field = 'tag'
+ self.activitypub_field = "tag"
def field_to_activity(self, value):
tags = []
for item in value.all():
activity_type = item.__class__.__name__
- if activity_type == 'User':
- activity_type = 'Mention'
- tags.append(activitypub.Link(
- href=item.remote_id,
- name=getattr(item, item.name_field),
- type=activity_type
- ))
+ if activity_type == "User":
+ activity_type = "Mention"
+ tags.append(
+ activitypub.Link(
+ href=item.remote_id,
+ name=getattr(item, item.name_field),
+ type=activity_type,
+ )
+ )
return tags
def field_from_activity(self, value):
@@ -310,37 +322,50 @@ class TagField(ManyToManyField):
items = []
for link_json in value:
link = activitypub.Link(**link_json)
- tag_type = link.type if link.type != 'Mention' else 'Person'
- if tag_type == 'Book':
- tag_type = 'Edition'
+ tag_type = link.type if link.type != "Mention" else "Person"
+ if tag_type == "Book":
+ tag_type = "Edition"
if tag_type != self.related_model.activity_serializer.type:
# tags can contain multiple types
continue
items.append(
- activitypub.resolve_remote_id(self.related_model, link.href)
+ activitypub.resolve_remote_id(link.href, model=self.related_model)
)
return items
+class ClearableFileInputWithWarning(ClearableFileInput):
+ """max file size warning"""
+
+ template_name = "widgets/clearable_file_input_with_warning.html"
+
+
+class CustomImageField(DjangoImageField):
+ """overwrites image field for form"""
+
+ widget = ClearableFileInputWithWarning
+
+
def image_serializer(value, alt):
- ''' helper for serializing images '''
- if value and hasattr(value, 'url'):
+ """helper for serializing images"""
+ if value and hasattr(value, "url"):
url = value.url
else:
return None
- url = 'https://%s%s' % (DOMAIN, url)
- return activitypub.Image(url=url, name=alt)
+ url = "https://%s%s" % (DOMAIN, url)
+ return activitypub.Document(url=url, name=alt)
class ImageField(ActivitypubFieldMixin, models.ImageField):
- ''' activitypub-aware image field '''
+ """activitypub-aware image field"""
+
def __init__(self, *args, alt_field=None, **kwargs):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ
def set_field_from_activity(self, instance, data, save=True):
- ''' helper function for assinging a value to the field '''
+ """helper function for assinging a value to the field"""
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
@@ -357,17 +382,15 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
key = self.get_activitypub_field()
activity[key] = formatted
-
def field_to_activity(self, value, alt=None):
return image_serializer(value, alt)
-
def field_from_activity(self, value):
image_slug = value
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
- if isinstance(image_slug, dict):
- url = image_slug.get('url')
+ if hasattr(image_slug, "url"):
+ url = image_slug.url
elif isinstance(image_slug, str):
url = image_slug
else:
@@ -382,13 +405,23 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if not response:
return None
- image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_content = ContentFile(response.content)
+ image_name = str(uuid4()) + "." + imghdr.what(None, image_content.read())
return [image_name, image_content]
+ def formfield(self, **kwargs):
+ """special case for forms"""
+ return super().formfield(
+ **{
+ "form_class": CustomImageField,
+ **kwargs,
+ }
+ )
+
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
- ''' activitypub-aware datetime field '''
+ """activitypub-aware datetime field"""
+
def field_to_activity(self, value):
if not value:
return None
@@ -404,8 +437,10 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
except (ParserError, TypeError):
return None
+
class HtmlField(ActivitypubFieldMixin, models.TextField):
- ''' a text field for storing html '''
+ """a text field for storing html"""
+
def field_from_activity(self, value):
if not value or value == MISSING:
return None
@@ -413,19 +448,34 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
sanitizer.feed(value)
return sanitizer.get_output()
+
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
- ''' activitypub-aware array field '''
+ """activitypub-aware array field"""
+
def field_to_activity(self, value):
return [str(i) for i in value]
+
class CharField(ActivitypubFieldMixin, models.CharField):
- ''' activitypub-aware char field '''
+ """activitypub-aware char field"""
+
class TextField(ActivitypubFieldMixin, models.TextField):
- ''' activitypub-aware text field '''
+ """activitypub-aware text field"""
+
class BooleanField(ActivitypubFieldMixin, models.BooleanField):
- ''' activitypub-aware boolean field '''
+ """activitypub-aware boolean field"""
+
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
- ''' activitypub-aware boolean field '''
+ """activitypub-aware boolean field"""
+
+
+class DecimalField(ActivitypubFieldMixin, models.DecimalField):
+ """activitypub-aware boolean field"""
+
+ def field_to_activity(self, value):
+ if not value:
+ return None
+ return float(value)
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index 407d820b..f2993846 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -1,9 +1,8 @@
-''' track progress of goodreads imports '''
+""" track progress of goodreads imports """
import re
import dateutil.parser
from django.apps import apps
-from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
@@ -14,13 +13,14 @@ from .fields import PrivacyLevels
# Mapping goodreads -> bookwyrm shelf titles.
GOODREADS_SHELVES = {
- 'read': 'read',
- 'currently-reading': 'reading',
- 'to-read': 'to-read',
+ "read": "read",
+ "currently-reading": "reading",
+ "to-read": "to-read",
}
+
def unquote_string(text):
- ''' resolve csv quote weirdness '''
+ """resolve csv quote weirdness"""
match = re.match(r'="([^"]*)"', text)
if match:
return match.group(1)
@@ -28,63 +28,62 @@ def unquote_string(text):
def construct_search_term(title, author):
- ''' formulate a query for the data connector '''
+ """formulate a query for the data connector"""
# Strip brackets (usually series title from search term)
- title = re.sub(r'\s*\([^)]*\)\s*', '', title)
+ title = re.sub(r"\s*\([^)]*\)\s*", "", title)
# Open library doesn't like including author initials in search term.
- author = re.sub(r'(\w\.)+\s*', '', author)
+ author = re.sub(r"(\w\.)+\s*", "", author)
- return ' '.join([title, author])
+ return " ".join([title, author])
class ImportJob(models.Model):
- ''' entry for a specific request for book data import '''
+ """entry for a specific request for book data import"""
+
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_date = models.DateTimeField(default=timezone.now)
task_id = models.CharField(max_length=100, null=True)
include_reviews = models.BooleanField(default=True)
complete = models.BooleanField(default=False)
privacy = models.CharField(
- max_length=255,
- default='public',
- choices=PrivacyLevels.choices
+ max_length=255, default="public", choices=PrivacyLevels.choices
)
retry = models.BooleanField(default=False)
def save(self, *args, **kwargs):
- ''' save and notify '''
+ """save and notify"""
super().save(*args, **kwargs)
if self.complete:
notification_model = apps.get_model(
- 'bookwyrm.Notification', require_ready=True)
+ "bookwyrm.Notification", require_ready=True
+ )
notification_model.objects.create(
user=self.user,
- notification_type='IMPORT',
+ notification_type="IMPORT",
related_import=self,
)
class ImportItem(models.Model):
- ''' a single line of a csv being imported '''
- job = models.ForeignKey(
- ImportJob,
- on_delete=models.CASCADE,
- related_name='items')
+ """a single line of a csv being imported"""
+
+ job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
index = models.IntegerField()
- data = JSONField()
- book = models.ForeignKey(
- Book, on_delete=models.SET_NULL, null=True, blank=True)
+ data = models.JSONField()
+ book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
fail_reason = models.TextField(null=True)
def resolve(self):
- ''' try various ways to lookup a book '''
- self.book = (
- self.get_book_from_isbn() or
+ """try various ways to lookup a book"""
+ if self.isbn:
+ self.book = self.get_book_from_isbn()
+ else:
+ # don't fall back on title/author search is isbn is present.
+ # you're too likely to mismatch
self.get_book_from_title_author()
- )
def get_book_from_isbn(self):
- ''' search by isbn '''
+ """search by isbn"""
search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999
)
@@ -93,13 +92,9 @@ class ImportItem(models.Model):
return search_result.connector.get_or_create_book(search_result.key)
return None
-
def get_book_from_title_author(self):
- ''' search by title and author '''
- search_term = construct_search_term(
- self.data['Title'],
- self.data['Author']
- )
+ """search by title and author"""
+ search_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result(
search_term, min_confidence=0.999
)
@@ -108,70 +103,87 @@ class ImportItem(models.Model):
return search_result.connector.get_or_create_book(search_result.key)
return None
-
@property
def title(self):
- ''' get the book title '''
- return self.data['Title']
+ """get the book title"""
+ return self.data["Title"]
@property
def author(self):
- ''' get the book title '''
- return self.data['Author']
+ """get the book title"""
+ return self.data["Author"]
@property
def isbn(self):
- ''' pulls out the isbn13 field from the csv line data '''
- return unquote_string(self.data['ISBN13'])
+ """pulls out the isbn13 field from the csv line data"""
+ return unquote_string(self.data["ISBN13"])
@property
def shelf(self):
- ''' the goodreads shelf field '''
- if self.data['Exclusive Shelf']:
- return GOODREADS_SHELVES.get(self.data['Exclusive Shelf'])
+ """the goodreads shelf field"""
+ if self.data["Exclusive Shelf"]:
+ return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
return None
@property
def review(self):
- ''' a user-written review, to be imported with the book data '''
- return self.data['My Review']
+ """a user-written review, to be imported with the book data"""
+ return self.data["My Review"]
@property
def rating(self):
- ''' x/5 star rating for a book '''
- return int(self.data['My Rating'])
+ """x/5 star rating for a book"""
+ if self.data.get("My Rating", None):
+ return int(self.data["My Rating"])
+ return None
@property
def date_added(self):
- ''' when the book was added to this dataset '''
- if self.data['Date Added']:
- return timezone.make_aware(
- dateutil.parser.parse(self.data['Date Added']))
+ """when the book was added to this dataset"""
+ if self.data["Date Added"]:
+ return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
+ return None
+
+ @property
+ def date_started(self):
+ """when the book was started"""
+ if "Date Started" in self.data and self.data["Date Started"]:
+ return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
return None
@property
def date_read(self):
- ''' the date a book was completed '''
- if self.data['Date Read']:
- return timezone.make_aware(
- dateutil.parser.parse(self.data['Date Read']))
+ """the date a book was completed"""
+ if self.data["Date Read"]:
+ return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
return None
@property
def reads(self):
- ''' formats a read through dataset for the book in this line '''
- if (self.shelf == 'reading'
- and self.date_added and not self.date_read):
- return [ReadThrough(start_date=self.date_added)]
+ """formats a read through dataset for the book in this line"""
+ start_date = self.date_started
+
+ # Goodreads special case (no 'date started' field)
+ if (
+ (self.shelf == "reading" or (self.shelf == "read" and self.date_read))
+ and self.date_added
+ and not start_date
+ ):
+ start_date = self.date_added
+
+ if start_date and start_date is not None and not self.date_read:
+ return [ReadThrough(start_date=start_date)]
if self.date_read:
- return [ReadThrough(
- start_date=self.date_added,
- finish_date=self.date_read,
- )]
+ return [
+ ReadThrough(
+ start_date=start_date,
+ finish_date=self.date_read,
+ )
+ ]
return []
def __repr__(self):
- return "".format(self.data['Title'])
+ return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
def __str__(self):
- return "{} by {}".format(self.data['Title'], self.data['Author'])
+ return "{} by {}".format(self.data["Title"], self.data["Author"])
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index ef48ed95..2a5c3382 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -1,6 +1,7 @@
-''' make a list of books!! '''
+""" make a list of books!! """
from django.apps import apps
from django.db import models
+from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
@@ -9,86 +10,90 @@ from .base_model import BookWyrmModel
from . import fields
-CurationType = models.TextChoices('Curation', [
- 'closed',
- 'open',
- 'curated',
-])
+CurationType = models.TextChoices(
+ "Curation",
+ [
+ "closed",
+ "open",
+ "curated",
+ ],
+)
+
class List(OrderedCollectionMixin, BookWyrmModel):
- ''' a list of books '''
+ """a list of books"""
+
name = fields.CharField(max_length=100)
user = fields.ForeignKey(
- 'User', on_delete=models.PROTECT, activitypub_field='owner')
- description = fields.TextField(
- blank=True, null=True, activitypub_field='summary')
+ "User", on_delete=models.PROTECT, activitypub_field="owner"
+ )
+ description = fields.TextField(blank=True, null=True, activitypub_field="summary")
privacy = fields.PrivacyField()
curation = fields.CharField(
- max_length=255,
- default='closed',
- choices=CurationType.choices
+ max_length=255, default="closed", choices=CurationType.choices
)
books = models.ManyToManyField(
- 'Edition',
+ "Edition",
symmetrical=False,
- through='ListItem',
- through_fields=('book_list', 'book'),
+ through="ListItem",
+ through_fields=("book_list", "book"),
)
activity_serializer = activitypub.BookList
def get_remote_id(self):
- ''' don't want the user to be in there in this case '''
- return 'https://%s/list/%d' % (DOMAIN, self.id)
+ """don't want the user to be in there in this case"""
+ return "https://%s/list/%d" % (DOMAIN, self.id)
@property
def collection_queryset(self):
- ''' list of books for this shelf, overrides OrderedCollectionMixin '''
- return self.books.filter(
- listitem__approved=True
- ).all().order_by('listitem')
+ """list of books for this shelf, overrides OrderedCollectionMixin"""
+ return self.books.filter(listitem__approved=True).order_by("listitem")
class Meta:
- ''' default sorting '''
- ordering = ('-updated_date',)
+ """default sorting"""
+
+ ordering = ("-updated_date",)
class ListItem(CollectionItemMixin, BookWyrmModel):
- ''' ok '''
+ """ok"""
+
book = fields.ForeignKey(
- 'Edition', on_delete=models.PROTECT, activitypub_field='object')
- book_list = fields.ForeignKey(
- 'List', on_delete=models.CASCADE, activitypub_field='target')
+ "Edition", on_delete=models.PROTECT, activitypub_field="book"
+ )
+ book_list = models.ForeignKey("List", on_delete=models.CASCADE)
user = fields.ForeignKey(
- 'User',
- on_delete=models.PROTECT,
- activitypub_field='actor'
+ "User", on_delete=models.PROTECT, activitypub_field="actor"
)
notes = fields.TextField(blank=True, null=True)
approved = models.BooleanField(default=True)
- order = fields.IntegerField(blank=True, null=True)
- endorsement = models.ManyToManyField('User', related_name='endorsers')
+ order = fields.IntegerField()
+ endorsement = models.ManyToManyField("User", related_name="endorsers")
- activity_serializer = activitypub.AddListItem
- object_field = 'book'
- collection_field = 'book_list'
+ activity_serializer = activitypub.ListItem
+ collection_field = "book_list"
def save(self, *args, **kwargs):
- ''' create a notification too '''
+ """create a notification too"""
created = not bool(self.id)
super().save(*args, **kwargs)
+ # tick the updated date on the parent list
+ self.book_list.updated_date = timezone.now()
+ self.book_list.save(broadcast=False)
+
list_owner = self.book_list.user
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
- model = apps.get_model('bookwyrm.Notification', require_ready=True)
+ model = apps.get_model("bookwyrm.Notification", require_ready=True)
model.objects.create(
user=list_owner,
related_user=self.user,
related_list_item=self,
- notification_type='ADD',
+ notification_type="ADD",
)
-
class Meta:
- ''' an opinionated constraint! you can't put a book on a list twice '''
- unique_together = ('book', 'book_list')
- ordering = ('-created_date',)
+ # A book may only be placed into a list once, and each order in the list may be used only
+ # once
+ unique_together = (("book", "book_list"), ("order", "book_list"))
+ ordering = ("-created_date",)
diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py
index 0470b325..ff0b4e5a 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -1,47 +1,52 @@
-''' alert a user to activity '''
+""" alert a user to activity """
from django.db import models
from .base_model import BookWyrmModel
NotificationType = models.TextChoices(
- 'NotificationType',
- 'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD')
+ "NotificationType",
+ "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT",
+)
+
class Notification(BookWyrmModel):
- ''' you've been tagged, liked, followed, etc '''
- user = models.ForeignKey('User', on_delete=models.CASCADE)
- related_book = models.ForeignKey(
- 'Edition', on_delete=models.CASCADE, null=True)
+ """you've been tagged, liked, followed, etc"""
+
+ user = models.ForeignKey("User", on_delete=models.CASCADE)
+ related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
related_user = models.ForeignKey(
- 'User',
- on_delete=models.CASCADE, null=True, related_name='related_user')
- related_status = models.ForeignKey(
- 'Status', on_delete=models.CASCADE, null=True)
- related_import = models.ForeignKey(
- 'ImportJob', on_delete=models.CASCADE, null=True)
+ "User", on_delete=models.CASCADE, null=True, related_name="related_user"
+ )
+ related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
+ related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
- 'ListItem', on_delete=models.CASCADE, null=True)
+ "ListItem", on_delete=models.CASCADE, null=True
+ )
+ related_report = models.ForeignKey("Report", on_delete=models.CASCADE, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
- max_length=255, choices=NotificationType.choices)
+ max_length=255, choices=NotificationType.choices
+ )
def save(self, *args, **kwargs):
- ''' save, but don't make dupes '''
+ """save, but don't make dupes"""
# there's probably a better way to do this
if self.__class__.objects.filter(
- user=self.user,
- related_book=self.related_book,
- related_user=self.related_user,
- related_status=self.related_status,
- related_import=self.related_import,
- related_list_item=self.related_list_item,
- notification_type=self.notification_type,
- ).exists():
+ user=self.user,
+ related_book=self.related_book,
+ related_user=self.related_user,
+ related_status=self.related_status,
+ related_import=self.related_import,
+ related_list_item=self.related_list_item,
+ related_report=self.related_report,
+ notification_type=self.notification_type,
+ ).exists():
return
super().save(*args, **kwargs)
class Meta:
- ''' checks if notifcation is in enum list for valid types '''
+ """checks if notifcation is in enum list for valid types"""
+
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
index 2bec3a81..664daa13 100644
--- a/bookwyrm/models/readthrough.py
+++ b/bookwyrm/models/readthrough.py
@@ -1,58 +1,59 @@
-''' progress in a book '''
+""" progress in a book """
from django.db import models
from django.utils import timezone
from django.core import validators
from .base_model import BookWyrmModel
+
class ProgressMode(models.TextChoices):
- PAGE = 'PG', 'page'
- PERCENT = 'PCT', 'percent'
+ """types of prgress available"""
+
+ PAGE = "PG", "page"
+ PERCENT = "PCT", "percent"
+
class ReadThrough(BookWyrmModel):
- ''' Store a read through a book in the database. '''
- user = models.ForeignKey('User', on_delete=models.PROTECT)
- book = models.ForeignKey('Edition', on_delete=models.PROTECT)
+ """Store a read through a book in the database."""
+
+ user = models.ForeignKey("User", on_delete=models.PROTECT)
+ book = models.ForeignKey("Edition", on_delete=models.PROTECT)
progress = models.IntegerField(
- validators=[validators.MinValueValidator(0)],
- null=True,
- blank=True)
+ validators=[validators.MinValueValidator(0)], null=True, blank=True
+ )
progress_mode = models.CharField(
- max_length=3,
- choices=ProgressMode.choices,
- default=ProgressMode.PAGE)
- start_date = models.DateTimeField(
- blank=True,
- null=True)
- finish_date = models.DateTimeField(
- blank=True,
- null=True)
+ max_length=3, choices=ProgressMode.choices, default=ProgressMode.PAGE
+ )
+ start_date = models.DateTimeField(blank=True, null=True)
+ finish_date = models.DateTimeField(blank=True, null=True)
def save(self, *args, **kwargs):
- ''' update user active time '''
+ """update user active time"""
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
def create_update(self):
+ """add update to the readthrough"""
if self.progress:
return self.progressupdate_set.create(
- user=self.user,
- progress=self.progress,
- mode=self.progress_mode)
+ user=self.user, progress=self.progress, mode=self.progress_mode
+ )
+ return None
+
class ProgressUpdate(BookWyrmModel):
- ''' Store progress through a book in the database. '''
- user = models.ForeignKey('User', on_delete=models.PROTECT)
- readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE)
+ """Store progress through a book in the database."""
+
+ user = models.ForeignKey("User", on_delete=models.PROTECT)
+ readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
progress = models.IntegerField(validators=[validators.MinValueValidator(0)])
mode = models.CharField(
- max_length=3,
- choices=ProgressMode.choices,
- default=ProgressMode.PAGE)
+ max_length=3, choices=ProgressMode.choices, default=ProgressMode.PAGE
+ )
def save(self, *args, **kwargs):
- ''' update user active time '''
+ """update user active time"""
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index e2db5468..12f4c51a 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -1,70 +1,91 @@
-''' defines relationships between users '''
+""" defines relationships between users """
from django.apps import apps
-from django.db import models, transaction
+from django.db import models, transaction, IntegrityError
from django.db.models import Q
-from django.dispatch import receiver
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
+from .activitypub_mixin import generate_activity
from .base_model import BookWyrmModel
from . import fields
class UserRelationship(BookWyrmModel):
- ''' many-to-many through table for followers '''
+ """many-to-many through table for followers"""
+
user_subject = fields.ForeignKey(
- 'User',
+ "User",
on_delete=models.PROTECT,
- related_name='%(class)s_user_subject',
- activitypub_field='actor',
+ related_name="%(class)s_user_subject",
+ activitypub_field="actor",
)
user_object = fields.ForeignKey(
- 'User',
+ "User",
on_delete=models.PROTECT,
- related_name='%(class)s_user_object',
- activitypub_field='object',
+ related_name="%(class)s_user_object",
+ activitypub_field="object",
)
@property
def privacy(self):
- ''' all relationships are handled directly with the participants '''
- return 'direct'
+ """all relationships are handled directly with the participants"""
+ return "direct"
@property
def recipients(self):
- ''' the remote user needs to recieve direct broadcasts '''
+ """the remote user needs to recieve direct broadcasts"""
return [u for u in [self.user_subject, self.user_object] if not u.local]
class Meta:
- ''' relationships should be unique '''
+ """relationships should be unique"""
+
abstract = True
constraints = [
models.UniqueConstraint(
- fields=['user_subject', 'user_object'],
- name='%(class)s_unique'
+ fields=["user_subject", "user_object"], name="%(class)s_unique"
),
models.CheckConstraint(
- check=~models.Q(user_subject=models.F('user_object')),
- name='%(class)s_no_self'
- )
+ check=~models.Q(user_subject=models.F("user_object")),
+ name="%(class)s_no_self",
+ ),
]
- def get_remote_id(self, status=None):# pylint: disable=arguments-differ
- ''' use shelf identifier in remote_id '''
- status = status or 'follows'
+ def get_remote_id(self):
+ """use shelf identifier in remote_id"""
base_path = self.user_subject.remote_id
- return '%s#%s/%d' % (base_path, status, self.id)
+ return "%s#follows/%d" % (base_path, self.id)
-class UserFollows(ActivitypubMixin, UserRelationship):
- ''' Following a user '''
- status = 'follows'
- activity_serializer = activitypub.Follow
+class UserFollows(ActivityMixin, UserRelationship):
+ """Following a user"""
+ status = "follows"
+
+ def to_activity(self): # pylint: disable=arguments-differ
+ """overrides default to manually set serializer"""
+ return activitypub.Follow(**generate_activity(self))
+
+ def save(self, *args, **kwargs):
+ """really really don't let a user follow someone who blocked them"""
+ # blocking in either direction is a no-go
+ if UserBlocks.objects.filter(
+ Q(
+ user_subject=self.user_subject,
+ user_object=self.user_object,
+ )
+ | Q(
+ user_subject=self.user_object,
+ user_object=self.user_subject,
+ )
+ ).exists():
+ raise IntegrityError()
+ # don't broadcast this type of relationship -- accepts and requests
+ # are handled by the UserFollowRequest model
+ super().save(*args, broadcast=False, **kwargs)
@classmethod
def from_request(cls, follow_request):
- ''' converts a follow request into a follow relationship '''
+ """converts a follow request into a follow relationship"""
return cls.objects.create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
@@ -73,90 +94,103 @@ class UserFollows(ActivitypubMixin, UserRelationship):
class UserFollowRequest(ActivitypubMixin, UserRelationship):
- ''' following a user requires manual or automatic confirmation '''
- status = 'follow_request'
+ """following a user requires manual or automatic confirmation"""
+
+ status = "follow_request"
activity_serializer = activitypub.Follow
def save(self, *args, broadcast=True, **kwargs):
- ''' make sure the follow or block relationship doesn't already exist '''
- try:
- UserFollows.objects.get(
+ """make sure the follow or block relationship doesn't already exist"""
+ # if there's a request for a follow that already exists, accept it
+ # without changing the local database state
+ if UserFollows.objects.filter(
+ user_subject=self.user_subject,
+ user_object=self.user_object,
+ ).exists():
+ self.accept(broadcast_only=True)
+ return
+
+ # blocking in either direction is a no-go
+ if UserBlocks.objects.filter(
+ Q(
user_subject=self.user_subject,
user_object=self.user_object,
)
- # blocking in either direction is a no-go
- UserBlocks.objects.get(
- user_subject=self.user_subject,
- user_object=self.user_object,
- )
- UserBlocks.objects.get(
+ | Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
- return None
- except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
- super().save(*args, **kwargs)
+ ).exists():
+ raise IntegrityError()
+ super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject)
if self.user_object.local:
- model = apps.get_model('bookwyrm.Notification', require_ready=True)
- notification_type = 'FOLLOW_REQUEST' \
- if self.user_object.manually_approves_followers else 'FOLLOW'
+ manually_approves = self.user_object.manually_approves_followers
+ if not manually_approves:
+ self.accept()
+
+ model = apps.get_model("bookwyrm.Notification", require_ready=True)
+ notification_type = "FOLLOW_REQUEST" if manually_approves else "FOLLOW"
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
+ def get_accept_reject_id(self, status):
+ """get id for sending an accept or reject of a local user"""
- def accept(self):
- ''' turn this request into the real deal'''
+ base_path = self.user_object.remote_id
+ return "%s#%s/%d" % (base_path, status, self.id or 0)
+
+ def accept(self, broadcast_only=False):
+ """turn this request into the real deal"""
user = self.user_object
- activity = activitypub.Accept(
- id=self.get_remote_id(status='accepts'),
- actor=self.user_object.remote_id,
- object=self.to_activity()
- ).serialize()
+ if not self.user_subject.local:
+ activity = activitypub.Accept(
+ id=self.get_accept_reject_id(status="accepts"),
+ actor=self.user_object.remote_id,
+ object=self.to_activity(),
+ ).serialize()
+ self.broadcast(activity, user)
+ if broadcast_only:
+ return
+
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
- self.broadcast(activity, user)
-
-
def reject(self):
- ''' generate a Reject for this follow request '''
- user = self.user_object
- activity = activitypub.Reject(
- id=self.get_remote_id(status='rejects'),
- actor=self.user_object.remote_id,
- object=self.to_activity()
- ).serialize()
+ """generate a Reject for this follow request"""
+ if self.user_object.local:
+ activity = activitypub.Reject(
+ id=self.get_accept_reject_id(status="rejects"),
+ actor=self.user_object.remote_id,
+ object=self.to_activity(),
+ ).serialize()
+ self.broadcast(activity, self.user_object)
+
self.delete()
- self.broadcast(activity, user)
class UserBlocks(ActivityMixin, UserRelationship):
- ''' prevent another user from following you and seeing your posts '''
- status = 'blocks'
+ """prevent another user from following you and seeing your posts"""
+
+ status = "blocks"
activity_serializer = activitypub.Block
+ def save(self, *args, **kwargs):
+ """remove follow or follow request rels after a block is created"""
+ super().save(*args, **kwargs)
-@receiver(models.signals.post_save, sender=UserBlocks)
-#pylint: disable=unused-argument
-def execute_after_save(sender, instance, created, *args, **kwargs):
- ''' remove follow or follow request rels after a block is created '''
- UserFollows.objects.filter(
- Q(user_subject=instance.user_subject,
- user_object=instance.user_object) | \
- Q(user_subject=instance.user_object,
- user_object=instance.user_subject)
- ).delete()
- UserFollowRequest.objects.filter(
- Q(user_subject=instance.user_subject,
- user_object=instance.user_object) | \
- Q(user_subject=instance.user_object,
- user_object=instance.user_subject)
- ).delete()
+ UserFollows.objects.filter(
+ Q(user_subject=self.user_subject, user_object=self.user_object)
+ | Q(user_subject=self.user_object, user_object=self.user_subject)
+ ).delete()
+ UserFollowRequest.objects.filter(
+ Q(user_subject=self.user_subject, user_object=self.user_object)
+ | Q(user_subject=self.user_object, user_object=self.user_subject)
+ ).delete()
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
new file mode 100644
index 00000000..7ff4c909
--- /dev/null
+++ b/bookwyrm/models/report.py
@@ -0,0 +1,55 @@
+""" flagged for moderation """
+from django.apps import apps
+from django.db import models
+from django.db.models import F, Q
+from .base_model import BookWyrmModel
+
+
+class Report(BookWyrmModel):
+ """reported status or user"""
+
+ reporter = models.ForeignKey(
+ "User", related_name="reporter", on_delete=models.PROTECT
+ )
+ note = models.TextField(null=True, blank=True)
+ user = models.ForeignKey("User", on_delete=models.PROTECT)
+ statuses = models.ManyToManyField("Status", blank=True)
+ resolved = models.BooleanField(default=False)
+
+ def save(self, *args, **kwargs):
+ """notify admins when a report is created"""
+ super().save(*args, **kwargs)
+ user_model = apps.get_model("bookwyrm.User", require_ready=True)
+ # moderators and superusers should be notified
+ admins = user_model.objects.filter(
+ Q(user_permissions__name__in=["moderate_user", "moderate_post"])
+ | Q(is_superuser=True)
+ ).all()
+ notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
+ for admin in admins:
+ notification_model.objects.create(
+ user=admin,
+ related_report=self,
+ notification_type="REPORT",
+ )
+
+ class Meta:
+ """don't let users report themselves"""
+
+ constraints = [
+ models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
+ ]
+ ordering = ("-created_date",)
+
+
+class ReportComment(BookWyrmModel):
+ """updates on a report"""
+
+ user = models.ForeignKey("User", on_delete=models.PROTECT)
+ note = models.TextField()
+ report = models.ForeignKey(Report, on_delete=models.PROTECT)
+
+ class Meta:
+ """sort comments"""
+
+ ordering = ("-created_date",)
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index 921b8617..4110ae8d 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -1,4 +1,4 @@
-''' puttin' books on shelves '''
+""" puttin' books on shelves """
import re
from django.db import models
@@ -9,61 +9,81 @@ from . import fields
class Shelf(OrderedCollectionMixin, BookWyrmModel):
- ''' a list of books owned by a user '''
+ """a list of books owned by a user"""
+
+ TO_READ = "to-read"
+ READING = "reading"
+ READ_FINISHED = "read"
+
+ READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
+
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
user = fields.ForeignKey(
- 'User', on_delete=models.PROTECT, activitypub_field='owner')
+ "User", on_delete=models.PROTECT, activitypub_field="owner"
+ )
editable = models.BooleanField(default=True)
privacy = fields.PrivacyField()
books = models.ManyToManyField(
- 'Edition',
+ "Edition",
symmetrical=False,
- through='ShelfBook',
- through_fields=('shelf', 'book')
+ through="ShelfBook",
+ through_fields=("shelf", "book"),
)
activity_serializer = activitypub.Shelf
def save(self, *args, **kwargs):
- ''' set the identifier '''
+ """set the identifier"""
super().save(*args, **kwargs)
if not self.identifier:
- slug = re.sub(r'[^\w]', '', self.name).lower()
- self.identifier = '%s-%d' % (slug, self.id)
- super().save(*args, **kwargs)
+ self.identifier = self.get_identifier()
+ super().save(*args, **kwargs, broadcast=False)
+
+ def get_identifier(self):
+ """custom-shelf-123 for the url"""
+ slug = re.sub(r"[^\w]", "", self.name).lower()
+ return "{:s}-{:d}".format(slug, self.id)
@property
def collection_queryset(self):
- ''' list of books for this shelf, overrides OrderedCollectionMixin '''
- return self.books.all().order_by('shelfbook')
+ """list of books for this shelf, overrides OrderedCollectionMixin"""
+ return self.books.order_by("shelfbook")
def get_remote_id(self):
- ''' shelf identifier instead of id '''
+ """shelf identifier instead of id"""
base_path = self.user.remote_id
- return '%s/shelf/%s' % (base_path, self.identifier)
+ identifier = self.identifier or self.get_identifier()
+ return "%s/books/%s" % (base_path, identifier)
class Meta:
- ''' user/shelf unqiueness '''
- unique_together = ('user', 'identifier')
+ """user/shelf unqiueness"""
+
+ unique_together = ("user", "identifier")
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(
- 'Edition', on_delete=models.PROTECT, activitypub_field='object')
- shelf = fields.ForeignKey(
- 'Shelf', on_delete=models.PROTECT, activitypub_field='target')
+ "Edition", on_delete=models.PROTECT, activitypub_field="book"
+ )
+ shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT)
user = fields.ForeignKey(
- 'User', on_delete=models.PROTECT, activitypub_field='actor')
+ "User", on_delete=models.PROTECT, activitypub_field="actor"
+ )
- activity_serializer = activitypub.AddBook
- object_field = 'book'
- collection_field = 'shelf'
+ activity_serializer = activitypub.ShelfItem
+ collection_field = "shelf"
+ def save(self, *args, **kwargs):
+ if not self.user:
+ self.user = self.shelf.user
+ super().save(*args, **kwargs)
class Meta:
- ''' an opinionated constraint!
- you can't put a book on shelf twice '''
- unique_together = ('book', 'shelf')
- ordering = ('-created_date',)
+ """an opinionated constraint!
+ you can't put a book on shelf twice"""
+
+ unique_together = ("book", "shelf")
+ ordering = ("-created_date",)
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index 4670bd94..2c5a2164 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -1,42 +1,50 @@
-''' the particulars for this instance of BookWyrm '''
+""" the particulars for this instance of BookWyrm """
import base64
import datetime
from Crypto import Random
-from django.db import models
+from django.db import models, IntegrityError
from django.utils import timezone
from bookwyrm.settings import DOMAIN
+from .base_model import BookWyrmModel
from .user import User
+
class SiteSettings(models.Model):
- ''' customized settings for this instance '''
- name = models.CharField(default='BookWyrm', max_length=100)
+ """customized settings for this instance"""
+
+ name = models.CharField(default="BookWyrm", max_length=100)
instance_tagline = models.CharField(
- max_length=150, default='Social Reading and Reviewing')
- instance_description = models.TextField(
- default='This instance has no description.')
+ max_length=150, default="Social Reading and Reviewing"
+ )
+ instance_description = models.TextField(default="This instance has no description.")
+
+ # about page
registration_closed_text = models.TextField(
- default='Contact an administrator to get an invite')
- code_of_conduct = models.TextField(
- default='Add a code of conduct here.')
+ default="Contact an administrator to get an invite"
+ )
+ code_of_conduct = models.TextField(default="Add a code of conduct here.")
+ privacy_policy = models.TextField(default="Add a privacy policy here.")
+
+ # registration
allow_registration = models.BooleanField(default=True)
- logo = models.ImageField(
- upload_to='logos/', null=True, blank=True
- )
- logo_small = models.ImageField(
- upload_to='logos/', null=True, blank=True
- )
- favicon = models.ImageField(
- upload_to='logos/', null=True, blank=True
- )
+ allow_invite_requests = models.BooleanField(default=True)
+
+ # images
+ logo = models.ImageField(upload_to="logos/", null=True, blank=True)
+ logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
+ favicon = models.ImageField(upload_to="logos/", null=True, blank=True)
+
+ # footer
support_link = models.CharField(max_length=255, null=True, blank=True)
support_title = models.CharField(max_length=100, null=True, blank=True)
admin_email = models.EmailField(max_length=255, null=True, blank=True)
+ footer_item = models.TextField(null=True, blank=True)
@classmethod
def get(cls):
- ''' gets the site settings db entry or defaults '''
+ """gets the site settings db entry or defaults"""
try:
return cls.objects.get(id=1)
except cls.DoesNotExist:
@@ -44,48 +52,70 @@ class SiteSettings(models.Model):
default_settings.save()
return default_settings
+
def new_access_code():
- ''' the identifier for a user invite '''
- return base64.b32encode(Random.get_random_bytes(5)).decode('ascii')
+ """the identifier for a user invite"""
+ return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
+
class SiteInvite(models.Model):
- ''' gives someone access to create an account on the instance '''
+ """gives someone access to create an account on the instance"""
+
created_date = models.DateTimeField(auto_now_add=True)
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(blank=True, null=True)
use_limit = models.IntegerField(blank=True, null=True)
times_used = models.IntegerField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE)
+ invitees = models.ManyToManyField(User, related_name="invitees")
def valid(self):
- ''' make sure it hasn't expired or been used '''
- return (
- (self.expiry is None or self.expiry > timezone.now()) and
- (self.use_limit is None or self.times_used < self.use_limit))
+ """make sure it hasn't expired or been used"""
+ return (self.expiry is None or self.expiry > timezone.now()) and (
+ self.use_limit is None or self.times_used < self.use_limit
+ )
@property
def link(self):
- ''' formats the invite link '''
- return 'https://{}/invite/{}'.format(DOMAIN, self.code)
+ """formats the invite link"""
+ return "https://{}/invite/{}".format(DOMAIN, self.code)
+
+
+class InviteRequest(BookWyrmModel):
+ """prospective users can request an invite"""
+
+ email = models.EmailField(max_length=255, unique=True)
+ invite = models.ForeignKey(
+ SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
+ )
+ invite_sent = models.BooleanField(default=False)
+ ignored = models.BooleanField(default=False)
+
+ def save(self, *args, **kwargs):
+ """don't create a request for a registered email"""
+ if not self.id and User.objects.filter(email=self.email).exists():
+ raise IntegrityError()
+ super().save(*args, **kwargs)
def get_passowrd_reset_expiry():
- ''' give people a limited time to use the link '''
+ """give people a limited time to use the link"""
now = timezone.now()
return now + datetime.timedelta(days=1)
class PasswordReset(models.Model):
- ''' gives someone access to create an account on the instance '''
+ """gives someone access to create an account on the instance"""
+
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
user = models.OneToOneField(User, on_delete=models.CASCADE)
def valid(self):
- ''' make sure it hasn't expired or been used '''
+ """make sure it hasn't expired or been used"""
return self.expiry > timezone.now()
@property
def link(self):
- ''' formats the invite link '''
- return 'https://{}/password-reset/{}'.format(DOMAIN, self.code)
+ """formats the invite link"""
+ return "https://{}/password-reset/{}".format(DOMAIN, self.code)
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index 62effeb8..bd21ec56 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -1,10 +1,11 @@
-''' models for storing different kinds of Activities '''
+""" models for storing different kinds of Activities """
from dataclasses import MISSING
import re
from django.apps import apps
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
+from django.template.loader import get_template
from django.utils import timezone
from model_utils.managers import InheritanceManager
@@ -13,204 +14,269 @@ from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
from .fields import image_serializer
+from .readthrough import ProgressMode
from . import fields
class Status(OrderedCollectionPageMixin, BookWyrmModel):
- ''' any post, like a reply to a review, etc '''
+ """any post, like a reply to a review, etc"""
+
user = fields.ForeignKey(
- 'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
+ "User", on_delete=models.PROTECT, activitypub_field="attributedTo"
+ )
content = fields.HtmlField(blank=True, null=True)
- mention_users = fields.TagField('User', related_name='mention_user')
- mention_books = fields.TagField('Edition', related_name='mention_book')
+ mention_users = fields.TagField("User", related_name="mention_user")
+ mention_books = fields.TagField("Edition", related_name="mention_book")
local = models.BooleanField(default=True)
content_warning = fields.CharField(
- max_length=500, blank=True, null=True, activitypub_field='summary')
+ max_length=500, blank=True, null=True, activitypub_field="summary"
+ )
privacy = fields.PrivacyField(max_length=255)
sensitive = fields.BooleanField(default=False)
# created date is different than publish date because of federated posts
published_date = fields.DateTimeField(
- default=timezone.now, activitypub_field='published')
+ default=timezone.now, activitypub_field="published"
+ )
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
- 'User',
+ "User",
symmetrical=False,
- through='Favorite',
- through_fields=('status', 'user'),
- related_name='user_favorites'
+ through="Favorite",
+ through_fields=("status", "user"),
+ related_name="user_favorites",
)
reply_parent = fields.ForeignKey(
- 'self',
+ "self",
null=True,
on_delete=models.PROTECT,
- activitypub_field='inReplyTo',
+ activitypub_field="inReplyTo",
)
objects = InheritanceManager()
activity_serializer = activitypub.Note
- serialize_reverse_fields = [('attachments', 'attachment', 'id')]
- deserialize_reverse_fields = [('attachments', 'attachment')]
+ serialize_reverse_fields = [("attachments", "attachment", "id")]
+ deserialize_reverse_fields = [("attachments", "attachment")]
+ class Meta:
+ """default sorting"""
+
+ ordering = ("-published_date",)
def save(self, *args, **kwargs):
- ''' save and notify '''
+ """save and notify"""
super().save(*args, **kwargs)
- notification_model = apps.get_model(
- 'bookwyrm.Notification', require_ready=True)
+ notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
if self.deleted:
notification_model.objects.filter(related_status=self).delete()
+ return
- if self.reply_parent and self.reply_parent.user != self.user and \
- self.reply_parent.user.local:
+ if (
+ self.reply_parent
+ and self.reply_parent.user != self.user
+ and self.reply_parent.user.local
+ ):
notification_model.objects.create(
user=self.reply_parent.user,
- notification_type='REPLY',
+ notification_type="REPLY",
related_user=self.user,
related_status=self,
)
for mention_user in self.mention_users.all():
# avoid double-notifying about this status
- if not mention_user.local or \
- (self.reply_parent and \
- mention_user == self.reply_parent.user):
+ if not mention_user.local or (
+ self.reply_parent and mention_user == self.reply_parent.user
+ ):
continue
notification_model.objects.create(
user=mention_user,
- notification_type='MENTION',
+ notification_type="MENTION",
related_user=self.user,
related_status=self,
)
+ def delete(self, *args, **kwargs): # pylint: disable=unused-argument
+ """ "delete" a status"""
+ if hasattr(self, "boosted_status"):
+ # okay but if it's a boost really delete it
+ super().delete(*args, **kwargs)
+ return
+ self.deleted = True
+ self.deleted_date = timezone.now()
+ self.save()
+
@property
def recipients(self):
- ''' tagged users who definitely need to get this status in broadcast '''
+ """tagged users who definitely need to get this status in broadcast"""
mentions = [u for u in self.mention_users.all() if not u.local]
- if hasattr(self, 'reply_parent') and self.reply_parent \
- and not self.reply_parent.user.local:
+ if (
+ hasattr(self, "reply_parent")
+ and self.reply_parent
+ and not self.reply_parent.user.local
+ ):
mentions.append(self.reply_parent.user)
return list(set(mentions))
@classmethod
- def ignore_activity(cls, activity):
- ''' keep notes if they are replies to existing statuses '''
- if activity.type != 'Note':
+ def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
+ """keep notes if they are replies to existing statuses"""
+ if activity.type == "Announce":
+ try:
+ boosted = activitypub.resolve_remote_id(
+ activity.object, get_activity=True
+ )
+ except activitypub.ActivitySerializerError:
+ # if we can't load the status, definitely ignore it
+ return True
+ # keep the boost if we would keep the status
+ return cls.ignore_activity(boosted)
+
+ # keep if it if it's a custom type
+ if activity.type != "Note":
return False
- if cls.objects.filter(
- remote_id=activity.inReplyTo).exists():
+ # keep it if it's a reply to an existing status
+ if cls.objects.filter(remote_id=activity.inReplyTo).exists():
return False
# keep notes if they mention local users
if activity.tag == MISSING or activity.tag is None:
return True
- tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
+ tags = [l["href"] for l in activity.tag if l["type"] == "Mention"]
+ user_model = apps.get_model("bookwyrm.User", require_ready=True)
for tag in tags:
- user_model = apps.get_model('bookwyrm.User', require_ready=True)
- if user_model.objects.filter(
- remote_id=tag, local=True).exists():
+ if user_model.objects.filter(remote_id=tag, local=True).exists():
# we found a mention of a known use boost
return False
return True
@classmethod
def replies(cls, status):
- ''' load all replies to a status. idk if there's a better way
- to write this so it's just a property '''
- return cls.objects.filter(
- reply_parent=status
- ).select_subclasses().order_by('published_date')
+ """load all replies to a status. idk if there's a better way
+ to write this so it's just a property"""
+ return (
+ cls.objects.filter(reply_parent=status)
+ .select_subclasses()
+ .order_by("published_date")
+ )
@property
def status_type(self):
- ''' expose the type of status for the ui using activity type '''
+ """expose the type of status for the ui using activity type"""
return self.activity_serializer.__name__
@property
def boostable(self):
- ''' you can't boost dms '''
- return self.privacy in ['unlisted', 'public']
+ """you can't boost dms"""
+ return self.privacy in ["unlisted", "public"]
def to_replies(self, **kwargs):
- ''' helper function for loading AP serialized replies to a status '''
+ """helper function for loading AP serialized replies to a status"""
return self.to_ordered_collection(
self.replies(self),
- remote_id='%s/replies' % self.remote_id,
+ remote_id="%s/replies" % self.remote_id,
collection_only=True,
**kwargs
- )
+ ).serialize()
- def to_activity(self, pure=False):# pylint: disable=arguments-differ
- ''' return tombstone if the status is deleted '''
+ def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
+ """return tombstone if the status is deleted"""
if self.deleted:
return activitypub.Tombstone(
id=self.remote_id,
url=self.remote_id,
deleted=self.deleted_date.isoformat(),
- published=self.deleted_date.isoformat()
- ).serialize()
- activity = ActivitypubMixin.to_activity(self)
- activity['replies'] = self.to_replies()
+ published=self.deleted_date.isoformat(),
+ )
+ activity = ActivitypubMixin.to_activity_dataclass(self)
+ activity.replies = self.to_replies()
# "pure" serialization for non-bookwyrm instances
- if pure and hasattr(self, 'pure_content'):
- activity['content'] = self.pure_content
- if 'name' in activity:
- activity['name'] = self.pure_name
- activity['type'] = self.pure_type
- activity['attachment'] = [
- image_serializer(b.cover, b.alt_text) \
- for b in self.mention_books.all()[:4] if b.cover]
- if hasattr(self, 'book') and self.book.cover:
- activity['attachment'].append(
+ if pure and hasattr(self, "pure_content"):
+ activity.content = self.pure_content
+ if hasattr(activity, "name"):
+ activity.name = self.pure_name
+ activity.type = self.pure_type
+ activity.attachment = [
+ image_serializer(b.cover, b.alt_text)
+ for b in self.mention_books.all()[:4]
+ if b.cover
+ ]
+ if hasattr(self, "book") and self.book.cover:
+ activity.attachment.append(
image_serializer(self.book.cover, self.book.alt_text)
)
return activity
+ def to_activity(self, pure=False): # pylint: disable=arguments-differ
+ """json serialized activitypub class"""
+ return self.to_activity_dataclass(pure=pure).serialize()
+
class GeneratedNote(Status):
- ''' these are app-generated messages about user activity '''
+ """these are app-generated messages about user activity"""
+
@property
def pure_content(self):
- ''' indicate the book in question for mastodon (or w/e) users '''
+ """indicate the book in question for mastodon (or w/e) users"""
message = self.content
- books = ', '.join(
- '"%s" ' % (book.remote_id, book.title) \
+ books = ", ".join(
+ '"%s" ' % (book.remote_id, book.title)
for book in self.mention_books.all()
)
- return '%s %s %s' % (self.user.display_name, message, books)
+ return "%s %s %s" % (self.user.display_name, message, books)
activity_serializer = activitypub.GeneratedNote
- pure_type = 'Note'
+ pure_type = "Note"
class Comment(Status):
- ''' like a review but without a rating and transient '''
+ """like a review but without a rating and transient"""
+
book = fields.ForeignKey(
- 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
+ "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
+ )
+
+ # this is it's own field instead of a foreign key to the progress update
+ # so that the update can be deleted without impacting the status
+ progress = models.IntegerField(
+ validators=[MinValueValidator(0)], null=True, blank=True
+ )
+ progress_mode = models.CharField(
+ max_length=3,
+ choices=ProgressMode.choices,
+ default=ProgressMode.PAGE,
+ null=True,
+ blank=True,
+ )
@property
def pure_content(self):
- ''' indicate the book in question for mastodon (or w/e) users '''
- return '%s(comment on "%s" )
' % \
- (self.content, self.book.remote_id, self.book.title)
+ """indicate the book in question for mastodon (or w/e) users"""
+ return '%s(comment on "%s" )
' % (
+ self.content,
+ self.book.remote_id,
+ self.book.title,
+ )
activity_serializer = activitypub.Comment
- pure_type = 'Note'
+ pure_type = "Note"
class Quotation(Status):
- ''' like a review but without a rating and transient '''
+ """like a review but without a rating and transient"""
+
quote = fields.HtmlField()
book = fields.ForeignKey(
- 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
+ "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
+ )
@property
def pure_content(self):
- ''' indicate the book in question for mastodon (or w/e) users '''
- quote = re.sub(r'^', '
"', self.quote)
- quote = re.sub(r'
$', '"
', quote)
+ """indicate the book in question for mastodon (or w/e) users"""
+ quote = re.sub(r"^", '
"', self.quote)
+ quote = re.sub(r"
$", '"', quote)
return '%s -- "%s"
%s' % (
quote,
self.book.remote_id,
@@ -219,90 +285,111 @@ class Quotation(Status):
)
activity_serializer = activitypub.Quotation
- pure_type = 'Note'
+ pure_type = "Note"
class Review(Status):
- ''' a book review '''
+ """a book review"""
+
name = fields.CharField(max_length=255, null=True)
book = fields.ForeignKey(
- 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
- rating = fields.IntegerField(
+ "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
+ )
+ rating = fields.DecimalField(
default=None,
null=True,
blank=True,
- validators=[MinValueValidator(1), MaxValueValidator(5)]
+ validators=[MinValueValidator(1), MaxValueValidator(5)],
+ decimal_places=2,
+ max_digits=3,
)
@property
def pure_name(self):
- ''' clarify review names for mastodon serialization '''
- if self.rating:
- #pylint: disable=bad-string-format-type
- return 'Review of "%s" (%d stars): %s' % (
- self.book.title,
- self.rating,
- self.name
- )
- return 'Review of "%s": %s' % (
- self.book.title,
- self.name
- )
+ """clarify review names for mastodon serialization"""
+ template = get_template("snippets/generated_status/review_pure_name.html")
+ return template.render(
+ {"book": self.book, "rating": self.rating, "name": self.name}
+ ).strip()
@property
def pure_content(self):
- ''' indicate the book in question for mastodon (or w/e) users '''
+ """indicate the book in question for mastodon (or w/e) users"""
return self.content
activity_serializer = activitypub.Review
- pure_type = 'Article'
+ pure_type = "Article"
+
+
+class ReviewRating(Review):
+ """a subtype of review that only contains a rating"""
+
+ def save(self, *args, **kwargs):
+ if not self.rating:
+ raise ValueError("ReviewRating object must include a numerical rating")
+ return super().save(*args, **kwargs)
+
+ @property
+ def pure_content(self):
+ template = get_template("snippets/generated_status/rating.html")
+ return template.render({"book": self.book, "rating": self.rating}).strip()
+
+ activity_serializer = activitypub.Rating
+ pure_type = "Note"
class Boost(ActivityMixin, Status):
- ''' boost'ing a post '''
+ """boost'ing a post"""
+
boosted_status = fields.ForeignKey(
- 'Status',
+ "Status",
on_delete=models.PROTECT,
- related_name='boosters',
- activitypub_field='object',
+ related_name="boosters",
+ activitypub_field="object",
)
- activity_serializer = activitypub.Boost
+ activity_serializer = activitypub.Announce
def save(self, *args, **kwargs):
- ''' save and notify '''
- super().save(*args, **kwargs)
- if not self.boosted_status.user.local:
+ """save and notify"""
+ # This constraint can't work as it would cross tables.
+ # class Meta:
+ # unique_together = ('user', 'boosted_status')
+ if (
+ Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
+ .exclude(id=self.id)
+ .exists()
+ ):
return
- notification_model = apps.get_model(
- 'bookwyrm.Notification', require_ready=True)
+ super().save(*args, **kwargs)
+ if not self.boosted_status.user.local or self.boosted_status.user == self.user:
+ return
+
+ notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_model.objects.create(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
- notification_type='BOOST',
+ notification_type="BOOST",
)
def delete(self, *args, **kwargs):
- ''' delete and un-notify '''
- notification_model = apps.get_model(
- 'bookwyrm.Notification', require_ready=True)
+ """delete and un-notify"""
+ notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_model.objects.filter(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
- notification_type='BOOST',
+ notification_type="BOOST",
).delete()
super().delete(*args, **kwargs)
-
def __init__(self, *args, **kwargs):
- ''' the user field is "actor" here instead of "attributedTo" '''
+ """the user field is "actor" here instead of "attributedTo" """
super().__init__(*args, **kwargs)
- reserve_fields = ['user', 'boosted_status']
- self.simple_fields = [f for f in self.simple_fields if \
- f.name in reserve_fields]
+ reserve_fields = ["user", "boosted_status", "published_date", "privacy"]
+ self.simple_fields = [f for f in self.simple_fields if f.name in reserve_fields]
self.activity_fields = self.simple_fields
self.many_to_many_fields = []
self.image_fields = []
diff --git a/bookwyrm/models/tag.py b/bookwyrm/models/tag.py
deleted file mode 100644
index d75f6e05..00000000
--- a/bookwyrm/models/tag.py
+++ /dev/null
@@ -1,59 +0,0 @@
-''' models for storing different kinds of Activities '''
-import urllib.parse
-
-from django.db import models
-
-from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN
-from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
-from .base_model import BookWyrmModel
-from . import fields
-
-
-class Tag(OrderedCollectionMixin, BookWyrmModel):
- ''' freeform tags for books '''
- name = fields.CharField(max_length=100, unique=True)
- identifier = models.CharField(max_length=100)
-
- @classmethod
- def book_queryset(cls, identifier):
- ''' county of books associated with this tag '''
- return cls.objects.filter(
- identifier=identifier
- ).order_by('-updated_date')
-
- @property
- def collection_queryset(self):
- ''' books associated with this tag '''
- return self.book_queryset(self.identifier)
-
- def get_remote_id(self):
- ''' tag should use identifier not id in remote_id '''
- base_path = 'https://%s' % DOMAIN
- return '%s/tag/%s' % (base_path, self.identifier)
-
-
- def save(self, *args, **kwargs):
- ''' create a url-safe lookup key for the tag '''
- if not self.id:
- # add identifiers to new tags
- self.identifier = urllib.parse.quote_plus(self.name)
- super().save(*args, **kwargs)
-
-
-class UserTag(CollectionItemMixin, BookWyrmModel):
- ''' an instance of a tag on a book by a user '''
- user = fields.ForeignKey(
- 'User', on_delete=models.PROTECT, activitypub_field='actor')
- book = fields.ForeignKey(
- 'Edition', on_delete=models.PROTECT, activitypub_field='object')
- tag = fields.ForeignKey(
- 'Tag', on_delete=models.PROTECT, activitypub_field='target')
-
- activity_serializer = activitypub.AddBook
- object_field = 'book'
- collection_field = 'tag'
-
- class Meta:
- ''' unqiueness constraint '''
- unique_together = ('user', 'book', 'tag')
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index fe21b58f..8e153206 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -1,16 +1,17 @@
-''' database schema for user data '''
+""" database schema for user data """
import re
from urllib.parse import urlparse
from django.apps import apps
-from django.contrib.auth.models import AbstractUser
+from django.contrib.auth.models import AbstractUser, Group
+from django.contrib.postgres.fields import CICharField
from django.core.validators import MinValueValidator
from django.db import models
-from django.dispatch import receiver
from django.utils import timezone
+import pytz
from bookwyrm import activitypub
-from bookwyrm.connectors import get_data
+from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN
@@ -18,39 +19,42 @@ from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from bookwyrm.utils import regex
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
-from .base_model import BookWyrmModel
+from .base_model import BookWyrmModel, DeactivationReason
from .federated_server import FederatedServer
from . import fields, Review
class User(OrderedCollectionPageMixin, AbstractUser):
- ''' a user who wants to read books '''
+ """a user who wants to read books"""
+
username = fields.UsernameField()
email = models.EmailField(unique=True, null=True)
key_pair = fields.OneToOneField(
- 'KeyPair',
+ "KeyPair",
on_delete=models.CASCADE,
- blank=True, null=True,
- activitypub_field='publicKey',
- related_name='owner'
+ blank=True,
+ null=True,
+ activitypub_field="publicKey",
+ related_name="owner",
)
inbox = fields.RemoteIdField(unique=True)
shared_inbox = fields.RemoteIdField(
- activitypub_field='sharedInbox',
- activitypub_wrapper='endpoints',
+ activitypub_field="sharedInbox",
+ activitypub_wrapper="endpoints",
deduplication_field=False,
- null=True)
+ null=True,
+ )
federated_server = models.ForeignKey(
- 'FederatedServer',
+ "FederatedServer",
on_delete=models.PROTECT,
null=True,
blank=True,
)
- outbox = fields.RemoteIdField(unique=True)
+ outbox = fields.RemoteIdField(unique=True, null=True)
summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True)
- localname = models.CharField(
+ localname = CICharField(
max_length=255,
null=True,
unique=True,
@@ -59,210 +63,320 @@ class User(OrderedCollectionPageMixin, AbstractUser):
# name is your display name, which you can change at will
name = fields.CharField(max_length=100, null=True, blank=True)
avatar = fields.ImageField(
- upload_to='avatars/', blank=True, null=True,
- activitypub_field='icon', alt_field='alt_text')
+ upload_to="avatars/",
+ blank=True,
+ null=True,
+ activitypub_field="icon",
+ alt_field="alt_text",
+ )
followers = fields.ManyToManyField(
- 'self',
+ "self",
link_only=True,
symmetrical=False,
- through='UserFollows',
- through_fields=('user_object', 'user_subject'),
- related_name='following'
+ through="UserFollows",
+ through_fields=("user_object", "user_subject"),
+ related_name="following",
)
follow_requests = models.ManyToManyField(
- 'self',
+ "self",
symmetrical=False,
- through='UserFollowRequest',
- through_fields=('user_subject', 'user_object'),
- related_name='follower_requests'
+ through="UserFollowRequest",
+ through_fields=("user_subject", "user_object"),
+ related_name="follower_requests",
)
blocks = models.ManyToManyField(
- 'self',
+ "self",
symmetrical=False,
- through='UserBlocks',
- through_fields=('user_subject', 'user_object'),
- related_name='blocked_by'
+ through="UserBlocks",
+ through_fields=("user_subject", "user_object"),
+ related_name="blocked_by",
)
favorites = models.ManyToManyField(
- 'Status',
+ "Status",
symmetrical=False,
- through='Favorite',
- through_fields=('user', 'status'),
- related_name='favorite_statuses'
+ through="Favorite",
+ through_fields=("user", "status"),
+ related_name="favorite_statuses",
)
default_post_privacy = models.CharField(
max_length=255,
default='public',
choices=fields.PrivacyLevels.choices
)
- remote_id = fields.RemoteIdField(
- null=True, unique=True, activitypub_field='id')
+ remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id")
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = fields.BooleanField(default=False)
+ show_goal = models.BooleanField(default=True)
+ discoverable = fields.BooleanField(default=False)
+ preferred_timezone = models.CharField(
+ choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
+ default=str(pytz.utc),
+ max_length=255,
+ )
+ deactivation_reason = models.CharField(
+ max_length=255, choices=DeactivationReason.choices, null=True, blank=True
+ )
+
+ name_field = "username"
+ property_fields = [("following_link", "following")]
+
+ @property
+ def following_link(self):
+ """just how to find out the following info"""
+ return "{:s}/following".format(self.remote_id)
- name_field = 'username'
@property
def alt_text(self):
- ''' alt text with username '''
- return 'avatar for %s' % (self.localname or self.username)
+ """alt text with username"""
+ return "avatar for %s" % (self.localname or self.username)
@property
def display_name(self):
- ''' show the cleanest version of the user's name possible '''
- if self.name and self.name != '':
+ """show the cleanest version of the user's name possible"""
+ if self.name and self.name != "":
return self.name
return self.localname or self.username
+ @property
+ def deleted(self):
+ """for consistent naming"""
+ return not self.is_active
+
+ @property
+ def unread_notification_count(self):
+ """count of notifications, for the templates"""
+ return self.notification_set.filter(read=False).count()
+
+ @property
+ def has_unread_mentions(self):
+ """whether any of the unread notifications are conversations"""
+ return self.notification_set.filter(
+ read=False,
+ notification_type__in=["REPLY", "MENTION", "TAG", "REPORT"],
+ ).exists()
+
activity_serializer = activitypub.Person
+ @classmethod
+ def viewer_aware_objects(cls, viewer):
+ """the user queryset filtered for the context of the logged in user"""
+ queryset = cls.objects.filter(is_active=True)
+ if viewer and viewer.is_authenticated:
+ queryset = queryset.exclude(blocks=viewer)
+ return queryset
+
def to_outbox(self, filter_type=None, **kwargs):
- ''' an ordered collection of statuses '''
+ """an ordered collection of statuses"""
if filter_type:
filter_class = apps.get_model(
- 'bookwyrm.%s' % filter_type, require_ready=True)
+ "bookwyrm.%s" % filter_type, require_ready=True
+ )
if not issubclass(filter_class, Status):
raise TypeError(
- 'filter_status_class must be a subclass of models.Status')
+ "filter_status_class must be a subclass of models.Status"
+ )
queryset = filter_class.objects
else:
queryset = Status.objects
- queryset = queryset.filter(
- user=self,
- deleted=False,
- privacy__in=['public', 'unlisted'],
- ).select_subclasses().order_by('-published_date')
- return self.to_ordered_collection(queryset, \
- collection_only=True, remote_id=self.outbox, **kwargs)
+ queryset = (
+ queryset.filter(
+ user=self,
+ deleted=False,
+ privacy__in=["public", "unlisted"],
+ )
+ .select_subclasses()
+ .order_by("-published_date")
+ )
+ return self.to_ordered_collection(
+ queryset, collection_only=True, remote_id=self.outbox, **kwargs
+ ).serialize()
def to_following_activity(self, **kwargs):
- ''' activitypub following list '''
- remote_id = '%s/following' % self.remote_id
+ """activitypub following list"""
+ remote_id = "%s/following" % self.remote_id
return self.to_ordered_collection(
- self.following.order_by('-updated_date').all(),
+ self.following.order_by("-updated_date").all(),
remote_id=remote_id,
id_only=True,
**kwargs
)
def to_followers_activity(self, **kwargs):
- ''' activitypub followers list '''
- remote_id = '%s/followers' % self.remote_id
+ """activitypub followers list"""
+ remote_id = "%s/followers" % self.remote_id
return self.to_ordered_collection(
- self.followers.order_by('-updated_date').all(),
+ self.followers.order_by("-updated_date").all(),
remote_id=remote_id,
id_only=True,
**kwargs
)
- def to_activity(self):
- ''' override default AP serializer to add context object
- idk if this is the best way to go about this '''
- activity_object = super().to_activity()
- activity_object['@context'] = [
- 'https://www.w3.org/ns/activitystreams',
- 'https://w3id.org/security/v1',
+ def to_activity(self, **kwargs):
+ """override default AP serializer to add context object
+ idk if this is the best way to go about this"""
+ if not self.is_active:
+ return self.remote_id
+
+ activity_object = super().to_activity(**kwargs)
+ activity_object["@context"] = [
+ "https://www.w3.org/ns/activitystreams",
+ "https://w3id.org/security/v1",
{
- 'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
- 'schema': 'http://schema.org#',
- 'PropertyValue': 'schema:PropertyValue',
- 'value': 'schema:value',
- }
+ "manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
+ "schema": "http://schema.org#",
+ "PropertyValue": "schema:PropertyValue",
+ "value": "schema:value",
+ },
]
return activity_object
-
def save(self, *args, **kwargs):
- ''' populate fields for new local users '''
- # this user already exists, no need to populate fields
+ """populate fields for new local users"""
+ created = not bool(self.id)
if not self.local and not re.match(regex.full_username, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
- self.username = '%s@%s' % (self.username, actor_parts.netloc)
- return super().save(*args, **kwargs)
+ self.username = "%s@%s" % (self.username, actor_parts.netloc)
+ super().save(*args, **kwargs)
- if self.id or not self.local:
- return super().save(*args, **kwargs)
+ # this user already exists, no need to populate fields
+ if not created:
+ super().save(*args, **kwargs)
+ return
+
+ # this is a new remote user, we need to set their remote server field
+ if not self.local:
+ super().save(*args, **kwargs)
+ set_remote_server.delay(self.id)
+ return
# populate fields for local users
- self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
- self.inbox = '%s/inbox' % self.remote_id
- self.shared_inbox = 'https://%s/inbox' % DOMAIN
- self.outbox = '%s/outbox' % self.remote_id
+ self.remote_id = "https://%s/user/%s" % (DOMAIN, self.localname)
+ self.inbox = "%s/inbox" % self.remote_id
+ self.shared_inbox = "https://%s/inbox" % DOMAIN
+ self.outbox = "%s/outbox" % self.remote_id
- return super().save(*args, **kwargs)
+ # an id needs to be set before we can proceed with related models
+ super().save(*args, **kwargs)
+
+ # make users editors by default
+ try:
+ self.groups.add(Group.objects.get(name="editor"))
+ except Group.DoesNotExist:
+ # this should only happen in tests
+ pass
+
+ # create keys and shelves for new local users
+ self.key_pair = KeyPair.objects.create(
+ remote_id="%s/#main-key" % self.remote_id
+ )
+ self.save(broadcast=False)
+
+ shelves = [
+ {
+ "name": "To Read",
+ "identifier": "to-read",
+ },
+ {
+ "name": "Currently Reading",
+ "identifier": "reading",
+ },
+ {
+ "name": "Read",
+ "identifier": "read",
+ },
+ ]
+
+ for shelf in shelves:
+ Shelf(
+ name=shelf["name"],
+ identifier=shelf["identifier"],
+ user=self,
+ editable=False,
+ ).save(broadcast=False)
+
+ def delete(self, *args, **kwargs):
+ """deactivate rather than delete a user"""
+ self.is_active = False
+ # skip the logic in this class's save()
+ super().save(*args, **kwargs)
@property
def local_path(self):
- ''' this model doesn't inherit bookwyrm model, so here we are '''
- return '/user/%s' % (self.localname or self.username)
+ """this model doesn't inherit bookwyrm model, so here we are"""
+ return "/user/%s" % (self.localname or self.username)
class KeyPair(ActivitypubMixin, BookWyrmModel):
- ''' public and private keys for a user '''
+ """public and private keys for a user"""
+
private_key = models.TextField(blank=True, null=True)
public_key = fields.TextField(
- blank=True, null=True, activitypub_field='publicKeyPem')
+ blank=True, null=True, activitypub_field="publicKeyPem"
+ )
activity_serializer = activitypub.PublicKey
- serialize_reverse_fields = [('owner', 'owner', 'id')]
+ serialize_reverse_fields = [("owner", "owner", "id")]
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
- return '%s/#main-key' % self.owner.remote_id
+ return "%s/#main-key" % self.owner.remote_id
def save(self, *args, **kwargs):
- ''' create a key pair '''
+ """create a key pair"""
# no broadcasting happening here
- if 'broadcast' in kwargs:
- del kwargs['broadcast']
+ if "broadcast" in kwargs:
+ del kwargs["broadcast"]
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
- def to_activity(self):
- ''' override default AP serializer to add context object
- idk if this is the best way to go about this '''
- activity_object = super().to_activity()
- del activity_object['@context']
- del activity_object['type']
+ def to_activity(self, **kwargs):
+ """override default AP serializer to add context object
+ idk if this is the best way to go about this"""
+ activity_object = super().to_activity(**kwargs)
+ del activity_object["@context"]
+ del activity_object["type"]
return activity_object
class AnnualGoal(BookWyrmModel):
- ''' set a goal for how many books you read in a year '''
- user = models.ForeignKey('User', on_delete=models.PROTECT)
- goal = models.IntegerField(
- validators=[MinValueValidator(1)]
- )
+ """set a goal for how many books you read in a year"""
+
+ user = models.ForeignKey("User", on_delete=models.PROTECT)
+ goal = models.IntegerField(validators=[MinValueValidator(1)])
year = models.IntegerField(default=timezone.now().year)
privacy = models.CharField(
- max_length=255,
- default='public',
- choices=fields.PrivacyLevels.choices
+ max_length=255, default="public", choices=fields.PrivacyLevels.choices
)
class Meta:
- ''' unqiueness constraint '''
- unique_together = ('user', 'year')
+ """unqiueness constraint"""
+
+ unique_together = ("user", "year")
def get_remote_id(self):
- ''' put the year in the path '''
- return '%s/goal/%d' % (self.user.remote_id, self.year)
+ """put the year in the path"""
+ return "%s/goal/%d" % (self.user.remote_id, self.year)
@property
def books(self):
- ''' the books you've read this year '''
- return self.user.readthrough_set.filter(
- finish_date__year__gte=self.year
- ).order_by('-finish_date').all()
-
+ """the books you've read this year"""
+ return (
+ self.user.readthrough_set.filter(
+ finish_date__year__gte=self.year,
+ finish_date__year__lt=self.year + 1,
+ )
+ .order_by("-finish_date")
+ .all()
+ )
@property
def ratings(self):
- ''' ratings for books read this year '''
+ """ratings for books read this year"""
book_ids = [r.book.id for r in self.books]
reviews = Review.objects.filter(
user=self.user,
@@ -270,102 +384,66 @@ class AnnualGoal(BookWyrmModel):
)
return {r.book.id: r.rating for r in reviews}
-
@property
- def progress_percent(self):
- ''' how close to your goal, in percent form '''
- return int(float(self.book_count / self.goal) * 100)
-
-
- @property
- def book_count(self):
- ''' how many books you've read this year '''
- return self.user.readthrough_set.filter(
- finish_date__year__gte=self.year).count()
-
-
-
-@receiver(models.signals.post_save, sender=User)
-#pylint: disable=unused-argument
-def execute_after_save(sender, instance, created, *args, **kwargs):
- ''' create shelves for new users '''
- if not created:
- return
-
- if not instance.local:
- set_remote_server.delay(instance.id)
- return
-
- instance.key_pair = KeyPair.objects.create(
- remote_id='%s/#main-key' % instance.remote_id)
- instance.save(broadcast=False)
-
- shelves = [{
- 'name': 'To Read',
- 'identifier': 'to-read',
- }, {
- 'name': 'Currently Reading',
- 'identifier': 'reading',
- }, {
- 'name': 'Read',
- 'identifier': 'read',
- }]
-
- for shelf in shelves:
- Shelf(
- name=shelf['name'],
- identifier=shelf['identifier'],
- user=instance,
- editable=False
- ).save(broadcast=False)
+ def progress(self):
+ """how many books you've read this year"""
+ count = self.user.readthrough_set.filter(
+ finish_date__year__gte=self.year,
+ finish_date__year__lt=self.year + 1,
+ ).count()
+ return {
+ "count": count,
+ "percent": int(float(count / self.goal) * 100),
+ }
@app.task
def set_remote_server(user_id):
- ''' figure out the user's remote server in the background '''
+ """figure out the user's remote server in the background"""
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
- user.federated_server = \
- get_or_create_remote_server(actor_parts.netloc)
- user.save()
- if user.bookwyrm_user:
+ user.federated_server = get_or_create_remote_server(actor_parts.netloc)
+ user.save(broadcast=False)
+ if user.bookwyrm_user and user.outbox:
get_remote_reviews.delay(user.outbox)
def get_or_create_remote_server(domain):
- ''' get info on a remote server '''
+ """get info on a remote server"""
try:
- return FederatedServer.objects.get(
- server_name=domain
- )
+ return FederatedServer.objects.get(server_name=domain)
except FederatedServer.DoesNotExist:
pass
- data = get_data('https://%s/.well-known/nodeinfo' % domain)
-
try:
- nodeinfo_url = data.get('links')[0].get('href')
- except (TypeError, KeyError):
- return None
+ data = get_data("https://%s/.well-known/nodeinfo" % domain)
+ try:
+ nodeinfo_url = data.get("links")[0].get("href")
+ except (TypeError, KeyError):
+ raise ConnectorException()
- data = get_data(nodeinfo_url)
+ data = get_data(nodeinfo_url)
+ application_type = data.get("software", {}).get("name")
+ application_version = data.get("software", {}).get("version")
+ except ConnectorException:
+ application_type = application_version = None
server = FederatedServer.objects.create(
server_name=domain,
- application_type=data['software']['name'],
- application_version=data['software']['version'],
+ application_type=application_type,
+ application_version=application_version,
)
return server
@app.task
def get_remote_reviews(outbox):
- ''' ingest reviews by a new remote bookwyrm user '''
- outbox_page = outbox + '?page=true&type=Review'
+ """ingest reviews by a new remote bookwyrm user"""
+ outbox_page = outbox + "?page=true&type=Review"
data = get_data(outbox_page)
# TODO: pagination?
- for activity in data['orderedItems']:
- if not activity['type'] == 'Review':
+ for activity in data["orderedItems"]:
+ if not activity["type"] == "Review":
continue
- activitypub.Review(**activity).to_model(Review)
+ activitypub.Review(**activity).to_model()
diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py
new file mode 100644
index 00000000..259bc4fd
--- /dev/null
+++ b/bookwyrm/redis_store.py
@@ -0,0 +1,86 @@
+""" access the activity stores stored in redis """
+from abc import ABC, abstractmethod
+import redis
+
+from bookwyrm import settings
+
+r = redis.Redis(
+ host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
+)
+
+
+class RedisStore(ABC):
+ """sets of ranked, related objects, like statuses for a user's feed"""
+
+ max_length = settings.MAX_STREAM_LENGTH
+
+ def get_value(self, obj):
+ """the object and rank"""
+ return {obj.id: self.get_rank(obj)}
+
+ def add_object_to_related_stores(self, obj, execute=True):
+ """add an object to all suitable stores"""
+ value = self.get_value(obj)
+ # we want to do this as a bulk operation, hence "pipeline"
+ pipeline = r.pipeline()
+ for store in self.get_stores_for_object(obj):
+ # add the status to the feed
+ pipeline.zadd(store, value)
+ # trim the store
+ pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
+ if not execute:
+ return pipeline
+ # and go!
+ return pipeline.execute()
+
+ def remove_object_from_related_stores(self, obj):
+ """remove an object from all stores"""
+ pipeline = r.pipeline()
+ for store in self.get_stores_for_object(obj):
+ pipeline.zrem(store, -1, obj.id)
+ pipeline.execute()
+
+ def bulk_add_objects_to_store(self, objs, store):
+ """add a list of objects to a given store"""
+ pipeline = r.pipeline()
+ for obj in objs[: self.max_length]:
+ pipeline.zadd(store, self.get_value(obj))
+ if objs:
+ pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
+ pipeline.execute()
+
+ def bulk_remove_objects_from_store(self, objs, store):
+ """remoev a list of objects from a given store"""
+ pipeline = r.pipeline()
+ for obj in objs[: self.max_length]:
+ pipeline.zrem(store, -1, obj.id)
+ pipeline.execute()
+
+ def get_store(self, store): # pylint: disable=no-self-use
+ """load the values in a store"""
+ return r.zrevrange(store, 0, -1)
+
+ def populate_store(self, store):
+ """go from zero to a store"""
+ pipeline = r.pipeline()
+ queryset = self.get_objects_for_store(store)
+
+ for obj in queryset[: self.max_length]:
+ pipeline.zadd(store, self.get_value(obj))
+
+ # only trim the store if objects were added
+ if queryset.exists():
+ pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
+ pipeline.execute()
+
+ @abstractmethod
+ def get_objects_for_store(self, store):
+ """a queryset of what should go in a store, used for populating it"""
+
+ @abstractmethod
+ def get_stores_for_object(self, obj):
+ """the stores that an object belongs in"""
+
+ @abstractmethod
+ def get_rank(self, obj):
+ """how to rank an object"""
diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py
index be7fb56f..0be64c58 100644
--- a/bookwyrm/sanitize_html.py
+++ b/bookwyrm/sanitize_html.py
@@ -1,56 +1,63 @@
-''' html parser to clean up incoming text from unknown sources '''
+""" html parser to clean up incoming text from unknown sources """
from html.parser import HTMLParser
-class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method
- ''' Removes any html that isn't allowed_tagsed from a block '''
+
+class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
+ """Removes any html that isn't allowed_tagsed from a block"""
def __init__(self):
HTMLParser.__init__(self)
self.allowed_tags = [
- 'p', 'blockquote', 'br',
- 'b', 'i', 'strong', 'em', 'pre',
- 'a', 'span', 'ul', 'ol', 'li'
+ "p",
+ "blockquote",
+ "br",
+ "b",
+ "i",
+ "strong",
+ "em",
+ "pre",
+ "a",
+ "span",
+ "ul",
+ "ol",
+ "li",
]
self.tag_stack = []
self.output = []
# if the html appears invalid, we just won't allow any at all
self.allow_html = True
-
def handle_starttag(self, tag, attrs):
- ''' check if the tag is valid '''
+ """check if the tag is valid"""
if self.allow_html and tag in self.allowed_tags:
- self.output.append(('tag', self.get_starttag_text()))
+ self.output.append(("tag", self.get_starttag_text()))
self.tag_stack.append(tag)
else:
- self.output.append(('data', ''))
-
+ self.output.append(("data", ""))
def handle_endtag(self, tag):
- ''' keep the close tag '''
+ """keep the close tag"""
if not self.allow_html or tag not in self.allowed_tags:
- self.output.append(('data', ''))
+ self.output.append(("data", ""))
return
if not self.tag_stack or self.tag_stack[-1] != tag:
# the end tag doesn't match the most recent start tag
self.allow_html = False
- self.output.append(('data', ''))
+ self.output.append(("data", ""))
return
self.tag_stack = self.tag_stack[:-1]
- self.output.append(('tag', '%s>' % tag))
-
+ self.output.append(("tag", "%s>" % tag))
def handle_data(self, data):
- ''' extract the answer, if we're in an answer tag '''
- self.output.append(('data', data))
-
+ """extract the answer, if we're in an answer tag"""
+ self.output.append(("data", data))
def get_output(self):
- ''' convert the output from a list of tuples to a string '''
+ """convert the output from a list of tuples to a string"""
if self.tag_stack:
self.allow_html = False
if not self.allow_html:
- return ''.join(v for (k, v) in self.output if k == 'data')
- return ''.join(v for (k, v) in self.output)
+ return "".join(v for (k, v) in self.output if k == "data")
+ return "".join(v for (k, v) in self.output)
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 46c38b5a..00c3d023 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -1,142 +1,167 @@
-''' bookwyrm settings and configuration '''
+""" bookwyrm settings and configuration """
import os
-
from environs import Env
import requests
+from django.utils.translation import gettext_lazy as _
+
env = Env()
-DOMAIN = env('DOMAIN')
-VERSION = '0.0.1'
+DOMAIN = env("DOMAIN")
+VERSION = "0.0.1"
-PAGE_LENGTH = env('PAGE_LENGTH', 15)
+PAGE_LENGTH = env("PAGE_LENGTH", 15)
+DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
# celery
-CELERY_BROKER = env('CELERY_BROKER')
-CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND')
-CELERY_ACCEPT_CONTENT = ['application/json']
-CELERY_TASK_SERIALIZER = 'json'
-CELERY_RESULT_SERIALIZER = 'json'
+CELERY_BROKER = "redis://:{}@redis_broker:{}/0".format(
+ requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
+)
+CELERY_RESULT_BACKEND = "redis://:{}@redis_broker:{}/0".format(
+ requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
+)
+CELERY_ACCEPT_CONTENT = ["application/json"]
+CELERY_TASK_SERIALIZER = "json"
+CELERY_RESULT_SERIALIZER = "json"
# email
-EMAIL_HOST = env('EMAIL_HOST')
-EMAIL_PORT = env('EMAIL_PORT', 587)
-EMAIL_HOST_USER = env('EMAIL_HOST_USER')
-EMAIL_HOST_PASSWORD = env('EMAIL_HOST_PASSWORD')
-EMAIL_USE_TLS = env('EMAIL_USE_TLS', True)
+EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
+EMAIL_HOST = env("EMAIL_HOST")
+EMAIL_PORT = env("EMAIL_PORT", 587)
+EMAIL_HOST_USER = env("EMAIL_HOST_USER")
+EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
+EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
+EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
+DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+LOCALE_PATHS = [
+ os.path.join(BASE_DIR, "locale"),
+]
+
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Quick-start development settings - unsuitable for production
-# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
+# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = env('SECRET_KEY')
+SECRET_KEY = env("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = env.bool('DEBUG', True)
+DEBUG = env.bool("DEBUG", True)
-ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', ['*'])
-OL_URL = env('OL_URL')
+ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"])
# Application definition
INSTALLED_APPS = [
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'django.contrib.humanize',
- 'django_rename_app',
- 'bookwyrm',
- 'celery',
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "django.contrib.humanize",
+ "django_rename_app",
+ "bookwyrm",
+ "celery",
]
MIDDLEWARE = [
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.locale.LocaleMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "bookwyrm.timezone_middleware.TimezoneMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
-ROOT_URLCONF = 'bookwyrm.urls'
+ROOT_URLCONF = "bookwyrm.urls"
TEMPLATES = [
{
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': ['templates'],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.contrib.messages.context_processors.messages',
- 'bookwyrm.context_processors.site_settings',
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": ["templates"],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ "bookwyrm.context_processors.site_settings",
],
},
},
]
-WSGI_APPLICATION = 'bookwyrm.wsgi.application'
+WSGI_APPLICATION = "bookwyrm.wsgi.application"
+# redis/activity streams settings
+REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
+REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
+REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
+
+MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
+STREAMS = ["home", "local", "federated"]
# Database
-# https://docs.djangoproject.com/en/2.0/ref/settings/#databases
-
-BOOKWYRM_DATABASE_BACKEND = env('BOOKWYRM_DATABASE_BACKEND', 'postgres')
-
-BOOKWYRM_DBS = {
- 'postgres': {
- 'ENGINE': 'django.db.backends.postgresql_psycopg2',
- 'NAME': env('POSTGRES_DB', 'fedireads'),
- 'USER': env('POSTGRES_USER', 'fedireads'),
- 'PASSWORD': env('POSTGRES_PASSWORD', 'fedireads'),
- 'HOST': env('POSTGRES_HOST', ''),
- 'PORT': 5432
- },
-}
+# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
- 'default': BOOKWYRM_DBS[BOOKWYRM_DATABASE_BACKEND]
+ "default": {
+ "ENGINE": "django.db.backends.postgresql_psycopg2",
+ "NAME": env("POSTGRES_DB", "fedireads"),
+ "USER": env("POSTGRES_USER", "fedireads"),
+ "PASSWORD": env("POSTGRES_PASSWORD", "fedireads"),
+ "HOST": env("POSTGRES_HOST", ""),
+ "PORT": env("POSTGRES_PORT", 5432),
+ },
}
-LOGIN_URL = '/login/'
-AUTH_USER_MODEL = 'bookwyrm.User'
+LOGIN_URL = "/login/"
+AUTH_USER_MODEL = "bookwyrm.User"
# Password validation
-# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators
+# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
+# pylint: disable=line-too-long
AUTH_PASSWORD_VALIDATORS = [
{
- 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
- 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
- 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
- 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
-# https://docs.djangoproject.com/en/2.0/topics/i18n/
+# https://docs.djangoproject.com/en/3.2/topics/i18n/
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = "en-us"
+LANGUAGES = [
+ ("en-us", _("English")),
+ ("de-de", _("German")),
+ ("es", _("Spanish")),
+ ("fr-fr", _("French")),
+ ("zh-hans", _("Simplified Chinese")),
+]
-TIME_ZONE = 'UTC'
+
+TIME_ZONE = "UTC"
USE_I18N = True
@@ -146,13 +171,16 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/2.0/howto/static-files/
+# https://docs.djangoproject.com/en/3.2/howto/static-files/
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
-STATIC_URL = '/static/'
-STATIC_ROOT = os.path.join(BASE_DIR, env('STATIC_ROOT', 'static'))
-MEDIA_URL = '/images/'
-MEDIA_ROOT = os.path.join(BASE_DIR, env('MEDIA_ROOT', 'images'))
+STATIC_URL = "/static/"
+STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
+MEDIA_URL = "/images/"
+MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
- requests.utils.default_user_agent(), VERSION, DOMAIN)
+ requests.utils.default_user_agent(),
+ VERSION,
+ DOMAIN,
+)
diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py
index ff281664..5488cf9b 100644
--- a/bookwyrm/signatures.py
+++ b/bookwyrm/signatures.py
@@ -1,4 +1,4 @@
-''' signs activitypub activities '''
+""" signs activitypub activities """
import hashlib
from urllib.parse import urlparse
import datetime
@@ -6,54 +6,56 @@ from base64 import b64encode, b64decode
from Crypto import Random
from Crypto.PublicKey import RSA
-from Crypto.Signature import pkcs1_15 #pylint: disable=no-name-in-module
+from Crypto.Signature import pkcs1_15 # pylint: disable=no-name-in-module
from Crypto.Hash import SHA256
MAX_SIGNATURE_AGE = 300
+
def create_key_pair():
- ''' a new public/private key pair, used for creating new users '''
+ """a new public/private key pair, used for creating new users"""
random_generator = Random.new().read
key = RSA.generate(1024, random_generator)
- private_key = key.export_key().decode('utf8')
- public_key = key.publickey().export_key().decode('utf8')
+ private_key = key.export_key().decode("utf8")
+ public_key = key.publickey().export_key().decode("utf8")
return private_key, public_key
def make_signature(sender, destination, date, digest):
- ''' uses a private key to sign an outgoing message '''
+ """uses a private key to sign an outgoing message"""
inbox_parts = urlparse(destination)
signature_headers = [
- '(request-target): post %s' % inbox_parts.path,
- 'host: %s' % inbox_parts.netloc,
- 'date: %s' % date,
- 'digest: %s' % digest,
+ "(request-target): post %s" % inbox_parts.path,
+ "host: %s" % inbox_parts.netloc,
+ "date: %s" % date,
+ "digest: %s" % digest,
]
- message_to_sign = '\n'.join(signature_headers)
+ message_to_sign = "\n".join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
- signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8')))
+ signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
signature = {
- 'keyId': '%s#main-key' % sender.remote_id,
- 'algorithm': 'rsa-sha256',
- 'headers': '(request-target) host date digest',
- 'signature': b64encode(signed_message).decode('utf8'),
+ "keyId": "%s#main-key" % sender.remote_id,
+ "algorithm": "rsa-sha256",
+ "headers": "(request-target) host date digest",
+ "signature": b64encode(signed_message).decode("utf8"),
}
- return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items())
+ return ",".join('%s="%s"' % (k, v) for (k, v) in signature.items())
def make_digest(data):
- ''' creates a message digest for signing '''
- return 'SHA-256=' + b64encode(hashlib.sha256(data.encode('utf-8'))\
- .digest()).decode('utf-8')
+ """creates a message digest for signing"""
+ return "SHA-256=" + b64encode(hashlib.sha256(data.encode("utf-8")).digest()).decode(
+ "utf-8"
+ )
def verify_digest(request):
- ''' checks if a digest is syntactically valid and matches the message '''
- algorithm, digest = request.headers['digest'].split('=', 1)
- if algorithm == 'SHA-256':
+ """checks if a digest is syntactically valid and matches the message"""
+ algorithm, digest = request.headers["digest"].split("=", 1)
+ if algorithm == "SHA-256":
hash_function = hashlib.sha256
- elif algorithm == 'SHA-512':
+ elif algorithm == "SHA-512":
hash_function = hashlib.sha512
else:
raise ValueError("Unsupported hash function: {}".format(algorithm))
@@ -62,8 +64,10 @@ def verify_digest(request):
if b64decode(digest) != expected:
raise ValueError("Invalid HTTP Digest header")
+
class Signature:
- ''' read and validate incoming signatures '''
+ """read and validate incoming signatures"""
+
def __init__(self, key_id, headers, signature):
self.key_id = key_id
self.headers = headers
@@ -71,42 +75,39 @@ class Signature:
@classmethod
def parse(cls, request):
- ''' extract and parse a signature from an http request '''
+ """extract and parse a signature from an http request"""
signature_dict = {}
- for pair in request.headers['Signature'].split(','):
- k, v = pair.split('=', 1)
- v = v.replace('"', '')
+ for pair in request.headers["Signature"].split(","):
+ k, v = pair.split("=", 1)
+ v = v.replace('"', "")
signature_dict[k] = v
try:
- key_id = signature_dict['keyId']
- headers = signature_dict['headers']
- signature = b64decode(signature_dict['signature'])
+ key_id = signature_dict["keyId"]
+ headers = signature_dict["headers"]
+ signature = b64decode(signature_dict["signature"])
except KeyError:
- raise ValueError('Invalid auth header')
+ raise ValueError("Invalid auth header")
return cls(key_id, headers, signature)
def verify(self, public_key, request):
- ''' verify rsa signature '''
- if http_date_age(request.headers['date']) > MAX_SIGNATURE_AGE:
- raise ValueError(
- "Request too old: %s" % (request.headers['date'],))
+ """verify rsa signature"""
+ if http_date_age(request.headers["date"]) > MAX_SIGNATURE_AGE:
+ raise ValueError("Request too old: %s" % (request.headers["date"],))
public_key = RSA.import_key(public_key)
comparison_string = []
- for signed_header_name in self.headers.split(' '):
- if signed_header_name == '(request-target)':
- comparison_string.append(
- '(request-target): post %s' % request.path)
+ for signed_header_name in self.headers.split(" "):
+ if signed_header_name == "(request-target)":
+ comparison_string.append("(request-target): post %s" % request.path)
else:
- if signed_header_name == 'digest':
+ if signed_header_name == "digest":
verify_digest(request)
- comparison_string.append('%s: %s' % (
- signed_header_name,
- request.headers[signed_header_name]
- ))
- comparison_string = '\n'.join(comparison_string)
+ comparison_string.append(
+ "%s: %s" % (signed_header_name, request.headers[signed_header_name])
+ )
+ comparison_string = "\n".join(comparison_string)
signer = pkcs1_15.new(public_key)
digest = SHA256.new()
@@ -117,7 +118,7 @@ class Signature:
def http_date_age(datestr):
- ''' age of a signature in seconds '''
- parsed = datetime.datetime.strptime(datestr, '%a, %d %b %Y %H:%M:%S GMT')
+ """age of a signature in seconds"""
+ parsed = datetime.datetime.strptime(datestr, "%a, %d %b %Y %H:%M:%S GMT")
delta = datetime.datetime.utcnow() - parsed
return delta.total_seconds()
diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css
new file mode 100644
index 00000000..3db25d1f
--- /dev/null
+++ b/bookwyrm/static/css/bookwyrm.css
@@ -0,0 +1,771 @@
+html {
+ scroll-behavior: smooth;
+}
+
+body {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+}
+
+.image {
+ overflow: hidden;
+}
+
+.navbar .logo {
+ max-height: 50px;
+}
+
+.card {
+ overflow: visible;
+}
+
+.scroll-x {
+ overflow: hidden;
+ overflow-x: auto;
+}
+
+.modal-card.is-fullwidth {
+ min-width: 75% !important;
+}
+
+/** Utilities not covered by Bulma
+ ******************************************************************************/
+
+@media only screen and (max-width: 768px) {
+ .is-sr-only-mobile {
+ border: none !important;
+ clip: rect(0, 0, 0, 0) !important;
+ height: 0.01em !important;
+ overflow: hidden !important;
+ padding: 0 !important;
+ position: absolute !important;
+ white-space: nowrap !important;
+ width: 0.01em !important;
+ }
+
+ .m-0-mobile {
+ margin: 0 !important;
+ }
+
+ .card-footer.is-stacked-mobile {
+ flex-direction: column;
+ }
+
+ .card-footer.is-stacked-mobile .card-footer-item:not(:last-child) {
+ border-bottom: 1px solid #ededed;
+ border-right: 0;
+ }
+}
+
+.button.is-transparent {
+ background-color: transparent;
+}
+
+.card.is-stretchable {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.card.is-stretchable .card-content {
+ flex-grow: 1;
+}
+
+/** Shelving
+ ******************************************************************************/
+
+/** @todo Replace icons with SVG symbols.
+ @see https://www.youtube.com/watch?v=9xXBYcWgCHA */
+.shelf-option:disabled > *::after {
+ font-family: "icomoon"; /* stylelint-disable font-family-no-missing-generic-family-keyword */
+ content: "\e918";
+ margin-left: 0.5em;
+}
+
+/** Toggles
+ ******************************************************************************/
+
+.toggle-button[aria-pressed=true],
+.toggle-button[aria-pressed=true]:hover {
+ background-color: hsl(171, 100%, 41%);
+ color: white;
+}
+
+.hide-active[aria-pressed=true],
+.hide-inactive[aria-pressed=false] {
+ display: none;
+}
+
+.transition-x.is-hidden,
+.transition-y.is-hidden {
+ display: block !important;
+ visibility: hidden !important;
+ height: 0 !important;
+ width: 0 !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ overflow: auto;
+}
+
+.transition-x,
+.transition-y {
+ transition-duration: 0.5s;
+ transition-timing-function: ease;
+}
+
+.transition-x {
+ transition-property: width, margin-left, margin-right, padding-left, padding-right;
+}
+
+.transition-y {
+ transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .transition-x,
+ .transition-y {
+ transition-duration: 0.001ms !important;
+ }
+}
+
+/** Stars
+ ******************************************************************************/
+
+.stars {
+ white-space: nowrap;
+}
+
+/** Stars in a review form
+ *
+ * Specificity makes hovering taking over checked inputs.
+ *
+ * \e9d9: filled star
+ * \e9d7: empty star;
+ * -------------------------------------------------------------------------- */
+
+.form-rate-stars {
+ width: max-content;
+}
+
+/* All stars are visually filled by default. */
+.form-rate-stars .icon::before {
+ content: '\e9d9';
+}
+
+/* Icons directly following inputs that follow the checked input are emptied. */
+.form-rate-stars input:checked ~ input + .icon::before {
+ content: '\e9d7';
+}
+
+/* When a label is hovered, repeat the fill-all-then-empty-following pattern. */
+.form-rate-stars:hover .icon.icon::before {
+ content: '\e9d9';
+}
+
+.form-rate-stars .icon:hover ~ .icon::before {
+ content: '\e9d7';
+}
+
+/** Book covers
+ *
+ * - .is-cover gives the behaviour of the cover and its surrounding. (optional)
+ * - .cover-container gives the dimensions and position (for borders, image and other elements).
+ * - .book-cover is positioned and sized based on its container.
+ *
+ * To have the cover within specific dimensions, specify a width or height for
+ * standard bulma’s named breapoints:
+ *
+ * `is-(w|h)-(auto|xs|s|m|l|xl|xxl)[-(mobile|tablet|desktop)]`
+ *
+ * The cover will be centered horizontally and vertically within those dimensions.
+ *
+ * When using `.column.is-N`, add `.is-w-auto` to the container so that the flex
+ * calculations are not biased by the default `max-content`.
+ ******************************************************************************/
+
+.column.is-cover {
+ flex-grow: 0 !important;
+}
+
+.column.is-cover,
+.column.is-cover + .column {
+ flex-basis: auto !important;
+}
+
+.cover-container {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ position: relative;
+ width: max-content;
+ max-width: 100%;
+ overflow: hidden;
+}
+
+/* Book cover
+ * -------------------------------------------------------------------------- */
+
+.book-cover {
+ display: block;
+ max-width: 100%;
+ max-height: 100%;
+
+ /* Useful when stretching under-sized images. */
+ image-rendering: optimizeQuality;
+ image-rendering: smooth;
+}
+
+/* Cover caption
+ * -------------------------------------------------------------------------- */
+
+.no-cover .cover_caption {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ padding: 0.25em;
+ font-size: 0.75em;
+ color: white;
+ background-color: #002549;
+}
+
+/** Avatars
+ ******************************************************************************/
+
+.avatar {
+ vertical-align: middle;
+ display: inline;
+}
+
+/** Statuses: Quotes
+ *
+ * \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
+ *
+ *
+ * User generated quote in markdown…
+ *
+ *
+ *
— Book Title by Author
+ *
+ * ```
+ ******************************************************************************/
+
+.quote > blockquote {
+ position: relative;
+ padding-left: 2em;
+}
+
+.quote > blockquote::before,
+.quote > blockquote::after {
+ font-family: 'icomoon';
+ position: absolute;
+}
+
+.quote > blockquote::before {
+ content: "\e906";
+ top: 0;
+ left: 0;
+}
+
+.quote > blockquote::after {
+ content: "\e905";
+ right: 0;
+}
+
+/* States
+ ******************************************************************************/
+
+/* "disabled" for non-buttons */
+
+.is-disabled {
+ background-color: #dbdbdb;
+ border-color: #dbdbdb;
+ box-shadow: none;
+ color: #7a7a7a;
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Book preview table
+ ******************************************************************************/
+
+.book-preview td {
+ vertical-align: middle;
+}
+
+@media only screen and (max-width: 768px) {
+ table.is-mobile,
+ table.is-mobile tbody {
+ display: block;
+ }
+
+ table.is-mobile tr {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ border-top: 1px solid #dbdbdb;
+ }
+
+ table.is-mobile td {
+ display: block;
+ box-sizing: border-box;
+ flex: 1 0 100%;
+ order: 2;
+ border-bottom: 0;
+ }
+
+ table.is-mobile td.book-preview-top-row {
+ order: 1;
+ flex-basis: auto;
+ }
+
+ table.is-mobile td[data-title]:not(:empty)::before {
+ content: attr(data-title);
+ display: block;
+ font-size: 0.75em;
+ font-weight: bold;
+ }
+
+ table.is-mobile td:empty {
+ padding: 0;
+ }
+
+ table.is-mobile th,
+ table.is-mobile thead {
+ display: none;
+ }
+}
+
+/* Book list
+ ******************************************************************************/
+
+ol.ordered-list {
+ list-style: none;
+ counter-reset: list-counter;
+}
+
+ol.ordered-list li {
+ counter-increment: list-counter;
+}
+
+ol.ordered-list li::before {
+ content: counter(list-counter);
+ position: absolute;
+ left: -20px;
+ width: 20px;
+ height: 24px;
+ background-color: #fff;
+ border: 1px solid #dbdbdb;
+ border-right: 0;
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: #888;
+ font-size: 0.8em;
+ font-weight: bold;
+}
+
+@media only screen and (max-width: 768px) {
+ ol.ordered-list li::before {
+ left: 0;
+ z-index: 1;
+ border: 0;
+ border-right: 1px solid #dbdbdb;
+ border-bottom: 1px solid #dbdbdb;
+ border-radius: 0;
+ border-bottom-right-radius: 2px;
+ }
+}
+
+/* Dimensions
+ * @todo These could be in rem.
+ ******************************************************************************/
+
+.is-32x32 {
+ min-width: 32px !important;
+ min-height: 32px !important;
+}
+
+.is-96x96 {
+ min-width: 96px !important;
+ min-height: 96px !important;
+}
+
+.is-w-auto {
+ width: auto !important;
+}
+
+.is-w-xs {
+ width: 80px !important;
+}
+
+.is-w-s {
+ width: 100px !important;
+}
+
+.is-w-m {
+ width: 150px !important;
+}
+
+.is-w-l {
+ width: 200px !important;
+}
+
+.is-w-xl {
+ width: 250px !important;
+}
+
+.is-w-xxl {
+ width: 500px !important;
+}
+
+.is-h-xs {
+ height: 80px !important;
+}
+
+.is-h-s {
+ height: 100px !important;
+}
+
+.is-h-m {
+ height: 150px !important;
+}
+
+.is-h-l {
+ height: 200px !important;
+}
+
+.is-h-xl {
+ height: 250px !important;
+}
+
+.is-h-xxl {
+ height: 500px !important;
+}
+
+@media only screen and (max-width: 768px) {
+ .is-w-auto-mobile {
+ width: auto !important;
+ }
+
+ .is-w-xs-mobile {
+ width: 80px !important;
+ }
+
+ .is-w-s-mobile {
+ width: 100px !important;
+ }
+
+ .is-w-m-mobile {
+ width: 150px !important;
+ }
+
+ .is-w-l-mobile {
+ width: 200px !important;
+ }
+
+ .is-w-xl-mobile {
+ width: 250px !important;
+ }
+
+ .is-w-xxl-mobile {
+ width: 500px !important;
+ }
+
+ .is-h-xs-mobile {
+ height: 80px !important;
+ }
+
+ .is-h-s-mobile {
+ height: 100px !important;
+ }
+
+ .is-h-m-mobile {
+ height: 150px !important;
+ }
+
+ .is-h-l-mobile {
+ height: 200px !important;
+ }
+
+ .is-h-xl-mobile {
+ height: 250px !important;
+ }
+
+ .is-h-xxl-mobile {
+ height: 500px !important;
+ }
+}
+
+@media only screen and (min-width: 769px) {
+ .is-w-auto-tablet {
+ width: auto !important;
+ }
+
+ .is-w-xs-tablet {
+ width: 80px !important;
+ }
+
+ .is-w-s-tablet {
+ width: 100px !important;
+ }
+
+ .is-w-m-tablet {
+ width: 150px !important;
+ }
+
+ .is-w-l-tablet {
+ width: 200px !important;
+ }
+
+ .is-w-xl-tablet {
+ width: 250px !important;
+ }
+
+ .is-w-xxl-tablet {
+ width: 500px !important;
+ }
+
+ .is-h-xs-tablet {
+ height: 80px !important;
+ }
+
+ .is-h-s-tablet {
+ height: 100px !important;
+ }
+
+ .is-h-m-tablet {
+ height: 150px !important;
+ }
+
+ .is-h-l-tablet {
+ height: 200px !important;
+ }
+
+ .is-h-xl-tablet {
+ height: 250px !important;
+ }
+
+ .is-h-xxl-tablet {
+ height: 500px !important;
+ }
+}
+
+@media only screen and (min-width: 1024px) {
+ .is-w-auto-desktop {
+ width: auto !important;
+ }
+
+ .is-w-xs-desktop {
+ width: 80px !important;
+ }
+
+ .is-w-s-desktop {
+ width: 100px !important;
+ }
+
+ .is-w-m-desktop {
+ width: 150px !important;
+ }
+
+ .is-w-l-desktop {
+ width: 200px !important;
+ }
+
+ .is-w-xl-desktop {
+ width: 250px !important;
+ }
+
+ .is-w-xxl-desktop {
+ width: 500px !important;
+ }
+
+ .is-h-xs-desktop {
+ height: 80px !important;
+ }
+
+ .is-h-s-desktop {
+ height: 100px !important;
+ }
+
+ .is-h-m-desktop {
+ height: 150px !important;
+ }
+
+ .is-h-l-desktop {
+ height: 200px !important;
+ }
+
+ .is-h-xl-desktop {
+ height: 250px !important;
+ }
+
+ .is-h-xxl-desktop {
+ height: 500px !important;
+ }
+}
+
+/* Alignments
+ *
+ * Use them with `.align.to-(c|t|r|b|l)[-(mobile|tablet)]`
+ ******************************************************************************/
+
+/* Flex item position
+ * -------------------------------------------------------------------------- */
+
+.align {
+ display: flex !important;
+ flex-direction: row !important;
+}
+
+.align.to-c {
+ justify-content: center !important;
+}
+
+.align.to-t {
+ align-items: flex-start !important;
+}
+
+.align.to-r {
+ justify-content: flex-end !important;
+}
+
+.align.to-b {
+ align-items: flex-end !important;
+}
+
+.align.to-l {
+ justify-content: flex-start !important;
+}
+
+@media screen and (max-width: 768px) {
+ .align.to-c-mobile {
+ justify-content: center !important;
+ }
+
+ .align.to-t-mobile {
+ align-items: flex-start !important;
+ }
+
+ .align.to-r-mobile {
+ justify-content: flex-end !important;
+ }
+
+ .align.to-b-mobile {
+ align-items: flex-end !important;
+ }
+
+ .align.to-l-mobile {
+ justify-content: flex-start !important;
+ }
+}
+
+@media screen and (min-width: 769px) {
+ .align.to-c-tablet {
+ justify-content: center !important;
+ }
+
+ .align.to-t-tablet {
+ align-items: flex-start !important;
+ }
+
+ .align.to-r-tablet {
+ justify-content: flex-end !important;
+ }
+
+ .align.to-b-tablet {
+ align-items: flex-end !important;
+ }
+
+ .align.to-l-tablet {
+ justify-content: flex-start !important;
+ }
+}
+
+/* Spacings
+ *
+ * Those are supplementary rules to Bulma’s. They follow the same conventions.
+ * Add those you’ll need.
+ ******************************************************************************/
+
+.mr-auto {
+ margin-right: auto !important;
+}
+
+.ml-auto {
+ margin-left: auto !important;
+}
+
+@media screen and (max-width: 768px) {
+ .m-0-mobile {
+ margin: 0 !important;
+ }
+
+ .mr-auto-mobile {
+ margin-right: auto !important;
+ }
+
+ .ml-auto-mobile {
+ margin-left: auto !important;
+ }
+
+ .mt-3-mobile {
+ margin-top: 0.75rem !important;
+ }
+
+ .ml-3-mobile {
+ margin-left: 0.75rem !important;
+ }
+
+ .mx-3-mobile {
+ margin-right: 0.75rem !important;
+ margin-left: 0.75rem !important;
+ }
+
+ .my-3-mobile {
+ margin-top: 0.75rem !important;
+ margin-bottom: 0.75rem !important;
+ }
+}
+
+@media screen and (min-width: 769px) {
+ .m-0-tablet {
+ margin: 0 !important;
+ }
+
+ .mr-auto-tablet {
+ margin-right: auto !important;
+ }
+
+ .ml-auto-tablet {
+ margin-left: auto !important;
+ }
+
+ .mt-3-tablet {
+ margin-top: 0.75rem !important;
+ }
+
+ .ml-3-tablet {
+ margin-left: 0.75rem !important;
+ }
+
+ .mx-3-tablet {
+ margin-right: 0.75rem !important;
+ margin-left: 0.75rem !important;
+ }
+
+ .my-3-tablet {
+ margin-top: 0.75rem !important;
+ margin-bottom: 0.75rem !important;
+ }
+}
diff --git a/bookwyrm/static/css/bulma.min.css b/bookwyrm/static/css/bulma.min.css
deleted file mode 100644
index a807a314..00000000
--- a/bookwyrm/static/css/bulma.min.css
+++ /dev/null
@@ -1 +0,0 @@
-/*! bulma.io v0.9.1 | MIT License | github.com/jgthms/bulma */@-webkit-keyframes spinAround{from{transform:rotate(0)}to{transform:rotate(359deg)}}@keyframes spinAround{from{transform:rotate(0)}to{transform:rotate(359deg)}}.breadcrumb,.button,.delete,.file,.is-unselectable,.modal-close,.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous,.tabs{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navbar-link:not(.is-arrowless)::after,.select:not(.is-multiple):not(.is-loading)::after{border:3px solid transparent;border-radius:2px;border-right:0;border-top:0;content:" ";display:block;height:.625em;margin-top:-.4375em;pointer-events:none;position:absolute;top:50%;transform:rotate(-45deg);transform-origin:center;width:.625em}.block:not(:last-child),.box:not(:last-child),.breadcrumb:not(:last-child),.content:not(:last-child),.highlight:not(:last-child),.level:not(:last-child),.message:not(:last-child),.notification:not(:last-child),.pagination:not(:last-child),.progress:not(:last-child),.subtitle:not(:last-child),.table-container:not(:last-child),.table:not(:last-child),.tabs:not(:last-child),.title:not(:last-child){margin-bottom:1.5rem}.delete,.modal-close{-moz-appearance:none;-webkit-appearance:none;background-color:rgba(10,10,10,.2);border:none;border-radius:290486px;cursor:pointer;pointer-events:auto;display:inline-block;flex-grow:0;flex-shrink:0;font-size:0;height:20px;max-height:20px;max-width:20px;min-height:20px;min-width:20px;outline:0;position:relative;vertical-align:top;width:20px}.delete::after,.delete::before,.modal-close::after,.modal-close::before{background-color:#fff;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.delete::before,.modal-close::before{height:2px;width:50%}.delete::after,.modal-close::after{height:50%;width:2px}.delete:focus,.delete:hover,.modal-close:focus,.modal-close:hover{background-color:rgba(10,10,10,.3)}.delete:active,.modal-close:active{background-color:rgba(10,10,10,.4)}.is-small.delete,.is-small.modal-close{height:16px;max-height:16px;max-width:16px;min-height:16px;min-width:16px;width:16px}.is-medium.delete,.is-medium.modal-close{height:24px;max-height:24px;max-width:24px;min-height:24px;min-width:24px;width:24px}.is-large.delete,.is-large.modal-close{height:32px;max-height:32px;max-width:32px;min-height:32px;min-width:32px;width:32px}.button.is-loading::after,.control.is-loading::after,.loader,.select.is-loading::after{-webkit-animation:spinAround .5s infinite linear;animation:spinAround .5s infinite linear;border:2px solid #dbdbdb;border-radius:290486px;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:1em;position:relative;width:1em}.hero-video,.image.is-16by9 .has-ratio,.image.is-16by9 img,.image.is-1by1 .has-ratio,.image.is-1by1 img,.image.is-1by2 .has-ratio,.image.is-1by2 img,.image.is-1by3 .has-ratio,.image.is-1by3 img,.image.is-2by1 .has-ratio,.image.is-2by1 img,.image.is-2by3 .has-ratio,.image.is-2by3 img,.image.is-3by1 .has-ratio,.image.is-3by1 img,.image.is-3by2 .has-ratio,.image.is-3by2 img,.image.is-3by4 .has-ratio,.image.is-3by4 img,.image.is-3by5 .has-ratio,.image.is-3by5 img,.image.is-4by3 .has-ratio,.image.is-4by3 img,.image.is-4by5 .has-ratio,.image.is-4by5 img,.image.is-5by3 .has-ratio,.image.is-5by3 img,.image.is-5by4 .has-ratio,.image.is-5by4 img,.image.is-9by16 .has-ratio,.image.is-9by16 img,.image.is-square .has-ratio,.image.is-square img,.is-overlay,.modal,.modal-background{bottom:0;left:0;position:absolute;right:0;top:0}.button,.file-cta,.file-name,.input,.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous,.select select,.textarea{-moz-appearance:none;-webkit-appearance:none;align-items:center;border:1px solid transparent;border-radius:4px;box-shadow:none;display:inline-flex;font-size:1rem;height:2.5em;justify-content:flex-start;line-height:1.5;padding-bottom:calc(.5em - 1px);padding-left:calc(.75em - 1px);padding-right:calc(.75em - 1px);padding-top:calc(.5em - 1px);position:relative;vertical-align:top}.button:active,.button:focus,.file-cta:active,.file-cta:focus,.file-name:active,.file-name:focus,.input:active,.input:focus,.is-active.button,.is-active.file-cta,.is-active.file-name,.is-active.input,.is-active.pagination-ellipsis,.is-active.pagination-link,.is-active.pagination-next,.is-active.pagination-previous,.is-active.textarea,.is-focused.button,.is-focused.file-cta,.is-focused.file-name,.is-focused.input,.is-focused.pagination-ellipsis,.is-focused.pagination-link,.is-focused.pagination-next,.is-focused.pagination-previous,.is-focused.textarea,.pagination-ellipsis:active,.pagination-ellipsis:focus,.pagination-link:active,.pagination-link:focus,.pagination-next:active,.pagination-next:focus,.pagination-previous:active,.pagination-previous:focus,.select select.is-active,.select select.is-focused,.select select:active,.select select:focus,.textarea:active,.textarea:focus{outline:0}.button[disabled],.file-cta[disabled],.file-name[disabled],.input[disabled],.pagination-ellipsis[disabled],.pagination-link[disabled],.pagination-next[disabled],.pagination-previous[disabled],.select fieldset[disabled] select,.select select[disabled],.textarea[disabled],fieldset[disabled] .button,fieldset[disabled] .file-cta,fieldset[disabled] .file-name,fieldset[disabled] .input,fieldset[disabled] .pagination-ellipsis,fieldset[disabled] .pagination-link,fieldset[disabled] .pagination-next,fieldset[disabled] .pagination-previous,fieldset[disabled] .select select,fieldset[disabled] .textarea{cursor:not-allowed}/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */blockquote,body,dd,dl,dt,fieldset,figure,h1,h2,h3,h4,h5,h6,hr,html,iframe,legend,li,ol,p,pre,textarea,ul{margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:400}ul{list-style:none}button,input,select,textarea{margin:0}html{box-sizing:border-box}*,::after,::before{box-sizing:inherit}img,video{height:auto;max-width:100%}iframe{border:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}td:not([align]),th:not([align]){text-align:inherit}html{background-color:#fff;font-size:16px;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:300px;overflow-x:hidden;overflow-y:scroll;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;-ms-text-size-adjust:100%;text-size-adjust:100%}article,aside,figure,footer,header,hgroup,section{display:block}body,button,input,optgroup,select,textarea{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif}code,pre{-moz-osx-font-smoothing:auto;-webkit-font-smoothing:auto;font-family:monospace}body{color:#4a4a4a;font-size:1em;font-weight:400;line-height:1.5}a{color:#3273dc;cursor:pointer;text-decoration:none}a strong{color:currentColor}a:hover{color:#363636}code{background-color:#f5f5f5;color:#da1039;font-size:.875em;font-weight:400;padding:.25em .5em .25em}hr{background-color:#f5f5f5;border:none;display:block;height:2px;margin:1.5rem 0}img{height:auto;max-width:100%}input[type=checkbox],input[type=radio]{vertical-align:baseline}small{font-size:.875em}span{font-style:inherit;font-weight:inherit}strong{color:#363636;font-weight:700}fieldset{border:none}pre{-webkit-overflow-scrolling:touch;background-color:#f5f5f5;color:#4a4a4a;font-size:.875em;overflow-x:auto;padding:1.25rem 1.5rem;white-space:pre;word-wrap:normal}pre code{background-color:transparent;color:currentColor;font-size:1em;padding:0}table td,table th{vertical-align:top}table td:not([align]),table th:not([align]){text-align:inherit}table th{color:#363636}.box{background-color:#fff;border-radius:6px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);color:#4a4a4a;display:block;padding:1.25rem}a.box:focus,a.box:hover{box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px #3273dc}a.box:active{box-shadow:inset 0 1px 2px rgba(10,10,10,.2),0 0 0 1px #3273dc}.button{background-color:#fff;border-color:#dbdbdb;border-width:1px;color:#363636;cursor:pointer;justify-content:center;padding-bottom:calc(.5em - 1px);padding-left:1em;padding-right:1em;padding-top:calc(.5em - 1px);text-align:center;white-space:nowrap}.button strong{color:inherit}.button .icon,.button .icon.is-large,.button .icon.is-medium,.button .icon.is-small{height:1.5em;width:1.5em}.button .icon:first-child:not(:last-child){margin-left:calc(-.5em - 1px);margin-right:.25em}.button .icon:last-child:not(:first-child){margin-left:.25em;margin-right:calc(-.5em - 1px)}.button .icon:first-child:last-child{margin-left:calc(-.5em - 1px);margin-right:calc(-.5em - 1px)}.button.is-hovered,.button:hover{border-color:#b5b5b5;color:#363636}.button.is-focused,.button:focus{border-color:#3273dc;color:#363636}.button.is-focused:not(:active),.button:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.button.is-active,.button:active{border-color:#4a4a4a;color:#363636}.button.is-text{background-color:transparent;border-color:transparent;color:#4a4a4a;text-decoration:underline}.button.is-text.is-focused,.button.is-text.is-hovered,.button.is-text:focus,.button.is-text:hover{background-color:#f5f5f5;color:#363636}.button.is-text.is-active,.button.is-text:active{background-color:#e8e8e8;color:#363636}.button.is-text[disabled],fieldset[disabled] .button.is-text{background-color:transparent;border-color:transparent;box-shadow:none}.button.is-white{background-color:#fff;border-color:transparent;color:#0a0a0a}.button.is-white.is-hovered,.button.is-white:hover{background-color:#f9f9f9;border-color:transparent;color:#0a0a0a}.button.is-white.is-focused,.button.is-white:focus{border-color:transparent;color:#0a0a0a}.button.is-white.is-focused:not(:active),.button.is-white:focus:not(:active){box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.button.is-white.is-active,.button.is-white:active{background-color:#f2f2f2;border-color:transparent;color:#0a0a0a}.button.is-white[disabled],fieldset[disabled] .button.is-white{background-color:#fff;border-color:transparent;box-shadow:none}.button.is-white.is-inverted{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted.is-hovered,.button.is-white.is-inverted:hover{background-color:#000}.button.is-white.is-inverted[disabled],fieldset[disabled] .button.is-white.is-inverted{background-color:#0a0a0a;border-color:transparent;box-shadow:none;color:#fff}.button.is-white.is-loading::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-white.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-white.is-outlined.is-focused,.button.is-white.is-outlined.is-hovered,.button.is-white.is-outlined:focus,.button.is-white.is-outlined:hover{background-color:#fff;border-color:#fff;color:#0a0a0a}.button.is-white.is-outlined.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-white.is-outlined.is-loading.is-focused::after,.button.is-white.is-outlined.is-loading.is-hovered::after,.button.is-white.is-outlined.is-loading:focus::after,.button.is-white.is-outlined.is-loading:hover::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-white.is-outlined[disabled],fieldset[disabled] .button.is-white.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-white.is-inverted.is-outlined.is-focused,.button.is-white.is-inverted.is-outlined.is-hovered,.button.is-white.is-inverted.is-outlined:focus,.button.is-white.is-inverted.is-outlined:hover{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-white.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-white.is-inverted.is-outlined.is-loading:focus::after,.button.is-white.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-white.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black{background-color:#0a0a0a;border-color:transparent;color:#fff}.button.is-black.is-hovered,.button.is-black:hover{background-color:#040404;border-color:transparent;color:#fff}.button.is-black.is-focused,.button.is-black:focus{border-color:transparent;color:#fff}.button.is-black.is-focused:not(:active),.button.is-black:focus:not(:active){box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.button.is-black.is-active,.button.is-black:active{background-color:#000;border-color:transparent;color:#fff}.button.is-black[disabled],fieldset[disabled] .button.is-black{background-color:#0a0a0a;border-color:transparent;box-shadow:none}.button.is-black.is-inverted{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted.is-hovered,.button.is-black.is-inverted:hover{background-color:#f2f2f2}.button.is-black.is-inverted[disabled],fieldset[disabled] .button.is-black.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#0a0a0a}.button.is-black.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-black.is-outlined.is-focused,.button.is-black.is-outlined.is-hovered,.button.is-black.is-outlined:focus,.button.is-black.is-outlined:hover{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.button.is-black.is-outlined.is-loading::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-black.is-outlined.is-loading.is-focused::after,.button.is-black.is-outlined.is-loading.is-hovered::after,.button.is-black.is-outlined.is-loading:focus::after,.button.is-black.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-black.is-outlined[disabled],fieldset[disabled] .button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-black.is-inverted.is-outlined.is-focused,.button.is-black.is-inverted.is-outlined.is-hovered,.button.is-black.is-inverted.is-outlined:focus,.button.is-black.is-inverted.is-outlined:hover{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-black.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-black.is-inverted.is-outlined.is-loading:focus::after,.button.is-black.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-black.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-light{background-color:#f5f5f5;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-hovered,.button.is-light:hover{background-color:#eee;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-focused,.button.is-light:focus{border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-focused:not(:active),.button.is-light:focus:not(:active){box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.button.is-light.is-active,.button.is-light:active{background-color:#e8e8e8;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light[disabled],fieldset[disabled] .button.is-light{background-color:#f5f5f5;border-color:transparent;box-shadow:none}.button.is-light.is-inverted{background-color:rgba(0,0,0,.7);color:#f5f5f5}.button.is-light.is-inverted.is-hovered,.button.is-light.is-inverted:hover{background-color:rgba(0,0,0,.7)}.button.is-light.is-inverted[disabled],fieldset[disabled] .button.is-light.is-inverted{background-color:rgba(0,0,0,.7);border-color:transparent;box-shadow:none;color:#f5f5f5}.button.is-light.is-loading::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;color:#f5f5f5}.button.is-light.is-outlined.is-focused,.button.is-light.is-outlined.is-hovered,.button.is-light.is-outlined:focus,.button.is-light.is-outlined:hover{background-color:#f5f5f5;border-color:#f5f5f5;color:rgba(0,0,0,.7)}.button.is-light.is-outlined.is-loading::after{border-color:transparent transparent #f5f5f5 #f5f5f5!important}.button.is-light.is-outlined.is-loading.is-focused::after,.button.is-light.is-outlined.is-loading.is-hovered::after,.button.is-light.is-outlined.is-loading:focus::after,.button.is-light.is-outlined.is-loading:hover::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined[disabled],fieldset[disabled] .button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;box-shadow:none;color:#f5f5f5}.button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-light.is-inverted.is-outlined.is-focused,.button.is-light.is-inverted.is-outlined.is-hovered,.button.is-light.is-inverted.is-outlined:focus,.button.is-light.is-inverted.is-outlined:hover{background-color:rgba(0,0,0,.7);color:#f5f5f5}.button.is-light.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-light.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-light.is-inverted.is-outlined.is-loading:focus::after,.button.is-light.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #f5f5f5 #f5f5f5!important}.button.is-light.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-dark{background-color:#363636;border-color:transparent;color:#fff}.button.is-dark.is-hovered,.button.is-dark:hover{background-color:#2f2f2f;border-color:transparent;color:#fff}.button.is-dark.is-focused,.button.is-dark:focus{border-color:transparent;color:#fff}.button.is-dark.is-focused:not(:active),.button.is-dark:focus:not(:active){box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.button.is-dark.is-active,.button.is-dark:active{background-color:#292929;border-color:transparent;color:#fff}.button.is-dark[disabled],fieldset[disabled] .button.is-dark{background-color:#363636;border-color:transparent;box-shadow:none}.button.is-dark.is-inverted{background-color:#fff;color:#363636}.button.is-dark.is-inverted.is-hovered,.button.is-dark.is-inverted:hover{background-color:#f2f2f2}.button.is-dark.is-inverted[disabled],fieldset[disabled] .button.is-dark.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#363636}.button.is-dark.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined{background-color:transparent;border-color:#363636;color:#363636}.button.is-dark.is-outlined.is-focused,.button.is-dark.is-outlined.is-hovered,.button.is-dark.is-outlined:focus,.button.is-dark.is-outlined:hover{background-color:#363636;border-color:#363636;color:#fff}.button.is-dark.is-outlined.is-loading::after{border-color:transparent transparent #363636 #363636!important}.button.is-dark.is-outlined.is-loading.is-focused::after,.button.is-dark.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-outlined.is-loading:focus::after,.button.is-dark.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-outlined{background-color:transparent;border-color:#363636;box-shadow:none;color:#363636}.button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-dark.is-inverted.is-outlined.is-focused,.button.is-dark.is-inverted.is-outlined.is-hovered,.button.is-dark.is-inverted.is-outlined:focus,.button.is-dark.is-inverted.is-outlined:hover{background-color:#fff;color:#363636}.button.is-dark.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-dark.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-inverted.is-outlined.is-loading:focus::after,.button.is-dark.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #363636 #363636!important}.button.is-dark.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary{background-color:#00d1b2;border-color:transparent;color:#fff}.button.is-primary.is-hovered,.button.is-primary:hover{background-color:#00c4a7;border-color:transparent;color:#fff}.button.is-primary.is-focused,.button.is-primary:focus{border-color:transparent;color:#fff}.button.is-primary.is-focused:not(:active),.button.is-primary:focus:not(:active){box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.button.is-primary.is-active,.button.is-primary:active{background-color:#00b89c;border-color:transparent;color:#fff}.button.is-primary[disabled],fieldset[disabled] .button.is-primary{background-color:#00d1b2;border-color:transparent;box-shadow:none}.button.is-primary.is-inverted{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted.is-hovered,.button.is-primary.is-inverted:hover{background-color:#f2f2f2}.button.is-primary.is-inverted[disabled],fieldset[disabled] .button.is-primary.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#00d1b2}.button.is-primary.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;color:#00d1b2}.button.is-primary.is-outlined.is-focused,.button.is-primary.is-outlined.is-hovered,.button.is-primary.is-outlined:focus,.button.is-primary.is-outlined:hover{background-color:#00d1b2;border-color:#00d1b2;color:#fff}.button.is-primary.is-outlined.is-loading::after{border-color:transparent transparent #00d1b2 #00d1b2!important}.button.is-primary.is-outlined.is-loading.is-focused::after,.button.is-primary.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-outlined.is-loading:focus::after,.button.is-primary.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;box-shadow:none;color:#00d1b2}.button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-primary.is-inverted.is-outlined.is-focused,.button.is-primary.is-inverted.is-outlined.is-hovered,.button.is-primary.is-inverted.is-outlined:focus,.button.is-primary.is-inverted.is-outlined:hover{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-primary.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-inverted.is-outlined.is-loading:focus::after,.button.is-primary.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #00d1b2 #00d1b2!important}.button.is-primary.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary.is-light{background-color:#ebfffc;color:#00947e}.button.is-primary.is-light.is-hovered,.button.is-primary.is-light:hover{background-color:#defffa;border-color:transparent;color:#00947e}.button.is-primary.is-light.is-active,.button.is-primary.is-light:active{background-color:#d1fff8;border-color:transparent;color:#00947e}.button.is-link{background-color:#3273dc;border-color:transparent;color:#fff}.button.is-link.is-hovered,.button.is-link:hover{background-color:#276cda;border-color:transparent;color:#fff}.button.is-link.is-focused,.button.is-link:focus{border-color:transparent;color:#fff}.button.is-link.is-focused:not(:active),.button.is-link:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.button.is-link.is-active,.button.is-link:active{background-color:#2366d1;border-color:transparent;color:#fff}.button.is-link[disabled],fieldset[disabled] .button.is-link{background-color:#3273dc;border-color:transparent;box-shadow:none}.button.is-link.is-inverted{background-color:#fff;color:#3273dc}.button.is-link.is-inverted.is-hovered,.button.is-link.is-inverted:hover{background-color:#f2f2f2}.button.is-link.is-inverted[disabled],fieldset[disabled] .button.is-link.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#3273dc}.button.is-link.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined{background-color:transparent;border-color:#3273dc;color:#3273dc}.button.is-link.is-outlined.is-focused,.button.is-link.is-outlined.is-hovered,.button.is-link.is-outlined:focus,.button.is-link.is-outlined:hover{background-color:#3273dc;border-color:#3273dc;color:#fff}.button.is-link.is-outlined.is-loading::after{border-color:transparent transparent #3273dc #3273dc!important}.button.is-link.is-outlined.is-loading.is-focused::after,.button.is-link.is-outlined.is-loading.is-hovered::after,.button.is-link.is-outlined.is-loading:focus::after,.button.is-link.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined[disabled],fieldset[disabled] .button.is-link.is-outlined{background-color:transparent;border-color:#3273dc;box-shadow:none;color:#3273dc}.button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-link.is-inverted.is-outlined.is-focused,.button.is-link.is-inverted.is-outlined.is-hovered,.button.is-link.is-inverted.is-outlined:focus,.button.is-link.is-inverted.is-outlined:hover{background-color:#fff;color:#3273dc}.button.is-link.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-link.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-link.is-inverted.is-outlined.is-loading:focus::after,.button.is-link.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #3273dc #3273dc!important}.button.is-link.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-link.is-light{background-color:#eef3fc;color:#2160c4}.button.is-link.is-light.is-hovered,.button.is-link.is-light:hover{background-color:#e3ecfa;border-color:transparent;color:#2160c4}.button.is-link.is-light.is-active,.button.is-link.is-light:active{background-color:#d8e4f8;border-color:transparent;color:#2160c4}.button.is-info{background-color:#3298dc;border-color:transparent;color:#fff}.button.is-info.is-hovered,.button.is-info:hover{background-color:#2793da;border-color:transparent;color:#fff}.button.is-info.is-focused,.button.is-info:focus{border-color:transparent;color:#fff}.button.is-info.is-focused:not(:active),.button.is-info:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.button.is-info.is-active,.button.is-info:active{background-color:#238cd1;border-color:transparent;color:#fff}.button.is-info[disabled],fieldset[disabled] .button.is-info{background-color:#3298dc;border-color:transparent;box-shadow:none}.button.is-info.is-inverted{background-color:#fff;color:#3298dc}.button.is-info.is-inverted.is-hovered,.button.is-info.is-inverted:hover{background-color:#f2f2f2}.button.is-info.is-inverted[disabled],fieldset[disabled] .button.is-info.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#3298dc}.button.is-info.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined{background-color:transparent;border-color:#3298dc;color:#3298dc}.button.is-info.is-outlined.is-focused,.button.is-info.is-outlined.is-hovered,.button.is-info.is-outlined:focus,.button.is-info.is-outlined:hover{background-color:#3298dc;border-color:#3298dc;color:#fff}.button.is-info.is-outlined.is-loading::after{border-color:transparent transparent #3298dc #3298dc!important}.button.is-info.is-outlined.is-loading.is-focused::after,.button.is-info.is-outlined.is-loading.is-hovered::after,.button.is-info.is-outlined.is-loading:focus::after,.button.is-info.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined[disabled],fieldset[disabled] .button.is-info.is-outlined{background-color:transparent;border-color:#3298dc;box-shadow:none;color:#3298dc}.button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-info.is-inverted.is-outlined.is-focused,.button.is-info.is-inverted.is-outlined.is-hovered,.button.is-info.is-inverted.is-outlined:focus,.button.is-info.is-inverted.is-outlined:hover{background-color:#fff;color:#3298dc}.button.is-info.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-info.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-info.is-inverted.is-outlined.is-loading:focus::after,.button.is-info.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #3298dc #3298dc!important}.button.is-info.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-info.is-light{background-color:#eef6fc;color:#1d72aa}.button.is-info.is-light.is-hovered,.button.is-info.is-light:hover{background-color:#e3f1fa;border-color:transparent;color:#1d72aa}.button.is-info.is-light.is-active,.button.is-info.is-light:active{background-color:#d8ebf8;border-color:transparent;color:#1d72aa}.button.is-success{background-color:#48c774;border-color:transparent;color:#fff}.button.is-success.is-hovered,.button.is-success:hover{background-color:#3ec46d;border-color:transparent;color:#fff}.button.is-success.is-focused,.button.is-success:focus{border-color:transparent;color:#fff}.button.is-success.is-focused:not(:active),.button.is-success:focus:not(:active){box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.button.is-success.is-active,.button.is-success:active{background-color:#3abb67;border-color:transparent;color:#fff}.button.is-success[disabled],fieldset[disabled] .button.is-success{background-color:#48c774;border-color:transparent;box-shadow:none}.button.is-success.is-inverted{background-color:#fff;color:#48c774}.button.is-success.is-inverted.is-hovered,.button.is-success.is-inverted:hover{background-color:#f2f2f2}.button.is-success.is-inverted[disabled],fieldset[disabled] .button.is-success.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#48c774}.button.is-success.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined{background-color:transparent;border-color:#48c774;color:#48c774}.button.is-success.is-outlined.is-focused,.button.is-success.is-outlined.is-hovered,.button.is-success.is-outlined:focus,.button.is-success.is-outlined:hover{background-color:#48c774;border-color:#48c774;color:#fff}.button.is-success.is-outlined.is-loading::after{border-color:transparent transparent #48c774 #48c774!important}.button.is-success.is-outlined.is-loading.is-focused::after,.button.is-success.is-outlined.is-loading.is-hovered::after,.button.is-success.is-outlined.is-loading:focus::after,.button.is-success.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined[disabled],fieldset[disabled] .button.is-success.is-outlined{background-color:transparent;border-color:#48c774;box-shadow:none;color:#48c774}.button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-success.is-inverted.is-outlined.is-focused,.button.is-success.is-inverted.is-outlined.is-hovered,.button.is-success.is-inverted.is-outlined:focus,.button.is-success.is-inverted.is-outlined:hover{background-color:#fff;color:#48c774}.button.is-success.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-success.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-success.is-inverted.is-outlined.is-loading:focus::after,.button.is-success.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #48c774 #48c774!important}.button.is-success.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-success.is-light{background-color:#effaf3;color:#257942}.button.is-success.is-light.is-hovered,.button.is-success.is-light:hover{background-color:#e6f7ec;border-color:transparent;color:#257942}.button.is-success.is-light.is-active,.button.is-success.is-light:active{background-color:#dcf4e4;border-color:transparent;color:#257942}.button.is-warning{background-color:#ffdd57;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-hovered,.button.is-warning:hover{background-color:#ffdb4a;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-focused,.button.is-warning:focus{border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-focused:not(:active),.button.is-warning:focus:not(:active){box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.button.is-warning.is-active,.button.is-warning:active{background-color:#ffd83d;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning[disabled],fieldset[disabled] .button.is-warning{background-color:#ffdd57;border-color:transparent;box-shadow:none}.button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);color:#ffdd57}.button.is-warning.is-inverted.is-hovered,.button.is-warning.is-inverted:hover{background-color:rgba(0,0,0,.7)}.button.is-warning.is-inverted[disabled],fieldset[disabled] .button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);border-color:transparent;box-shadow:none;color:#ffdd57}.button.is-warning.is-loading::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined{background-color:transparent;border-color:#ffdd57;color:#ffdd57}.button.is-warning.is-outlined.is-focused,.button.is-warning.is-outlined.is-hovered,.button.is-warning.is-outlined:focus,.button.is-warning.is-outlined:hover{background-color:#ffdd57;border-color:#ffdd57;color:rgba(0,0,0,.7)}.button.is-warning.is-outlined.is-loading::after{border-color:transparent transparent #ffdd57 #ffdd57!important}.button.is-warning.is-outlined.is-loading.is-focused::after,.button.is-warning.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-outlined.is-loading:focus::after,.button.is-warning.is-outlined.is-loading:hover::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-outlined{background-color:transparent;border-color:#ffdd57;box-shadow:none;color:#ffdd57}.button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-warning.is-inverted.is-outlined.is-focused,.button.is-warning.is-inverted.is-outlined.is-hovered,.button.is-warning.is-inverted.is-outlined:focus,.button.is-warning.is-inverted.is-outlined:hover{background-color:rgba(0,0,0,.7);color:#ffdd57}.button.is-warning.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-warning.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-inverted.is-outlined.is-loading:focus::after,.button.is-warning.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #ffdd57 #ffdd57!important}.button.is-warning.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-warning.is-light{background-color:#fffbeb;color:#947600}.button.is-warning.is-light.is-hovered,.button.is-warning.is-light:hover{background-color:#fff8de;border-color:transparent;color:#947600}.button.is-warning.is-light.is-active,.button.is-warning.is-light:active{background-color:#fff6d1;border-color:transparent;color:#947600}.button.is-danger{background-color:#f14668;border-color:transparent;color:#fff}.button.is-danger.is-hovered,.button.is-danger:hover{background-color:#f03a5f;border-color:transparent;color:#fff}.button.is-danger.is-focused,.button.is-danger:focus{border-color:transparent;color:#fff}.button.is-danger.is-focused:not(:active),.button.is-danger:focus:not(:active){box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.button.is-danger.is-active,.button.is-danger:active{background-color:#ef2e55;border-color:transparent;color:#fff}.button.is-danger[disabled],fieldset[disabled] .button.is-danger{background-color:#f14668;border-color:transparent;box-shadow:none}.button.is-danger.is-inverted{background-color:#fff;color:#f14668}.button.is-danger.is-inverted.is-hovered,.button.is-danger.is-inverted:hover{background-color:#f2f2f2}.button.is-danger.is-inverted[disabled],fieldset[disabled] .button.is-danger.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#f14668}.button.is-danger.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;color:#f14668}.button.is-danger.is-outlined.is-focused,.button.is-danger.is-outlined.is-hovered,.button.is-danger.is-outlined:focus,.button.is-danger.is-outlined:hover{background-color:#f14668;border-color:#f14668;color:#fff}.button.is-danger.is-outlined.is-loading::after{border-color:transparent transparent #f14668 #f14668!important}.button.is-danger.is-outlined.is-loading.is-focused::after,.button.is-danger.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-outlined.is-loading:focus::after,.button.is-danger.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;box-shadow:none;color:#f14668}.button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-danger.is-inverted.is-outlined.is-focused,.button.is-danger.is-inverted.is-outlined.is-hovered,.button.is-danger.is-inverted.is-outlined:focus,.button.is-danger.is-inverted.is-outlined:hover{background-color:#fff;color:#f14668}.button.is-danger.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-danger.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-inverted.is-outlined.is-loading:focus::after,.button.is-danger.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #f14668 #f14668!important}.button.is-danger.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-danger.is-light{background-color:#feecf0;color:#cc0f35}.button.is-danger.is-light.is-hovered,.button.is-danger.is-light:hover{background-color:#fde0e6;border-color:transparent;color:#cc0f35}.button.is-danger.is-light.is-active,.button.is-danger.is-light:active{background-color:#fcd4dc;border-color:transparent;color:#cc0f35}.button.is-small{border-radius:2px;font-size:.75rem}.button.is-normal{font-size:1rem}.button.is-medium{font-size:1.25rem}.button.is-large{font-size:1.5rem}.button[disabled],fieldset[disabled] .button{background-color:#fff;border-color:#dbdbdb;box-shadow:none;opacity:.5}.button.is-fullwidth{display:flex;width:100%}.button.is-loading{color:transparent!important;pointer-events:none}.button.is-loading::after{position:absolute;left:calc(50% - (1em / 2));top:calc(50% - (1em / 2));position:absolute!important}.button.is-static{background-color:#f5f5f5;border-color:#dbdbdb;color:#7a7a7a;box-shadow:none;pointer-events:none}.button.is-rounded{border-radius:290486px;padding-left:calc(1em + .25em);padding-right:calc(1em + .25em)}.buttons{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.buttons .button{margin-bottom:.5rem}.buttons .button:not(:last-child):not(.is-fullwidth){margin-right:.5rem}.buttons:last-child{margin-bottom:-.5rem}.buttons:not(:last-child){margin-bottom:1rem}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large){border-radius:2px;font-size:.75rem}.buttons.are-medium .button:not(.is-small):not(.is-normal):not(.is-large){font-size:1.25rem}.buttons.are-large .button:not(.is-small):not(.is-normal):not(.is-medium){font-size:1.5rem}.buttons.has-addons .button:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.buttons.has-addons .button:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0;margin-right:-1px}.buttons.has-addons .button:last-child{margin-right:0}.buttons.has-addons .button.is-hovered,.buttons.has-addons .button:hover{z-index:2}.buttons.has-addons .button.is-active,.buttons.has-addons .button.is-focused,.buttons.has-addons .button.is-selected,.buttons.has-addons .button:active,.buttons.has-addons .button:focus{z-index:3}.buttons.has-addons .button.is-active:hover,.buttons.has-addons .button.is-focused:hover,.buttons.has-addons .button.is-selected:hover,.buttons.has-addons .button:active:hover,.buttons.has-addons .button:focus:hover{z-index:4}.buttons.has-addons .button.is-expanded{flex-grow:1;flex-shrink:1}.buttons.is-centered{justify-content:center}.buttons.is-centered:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.buttons.is-right{justify-content:flex-end}.buttons.is-right:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.container{flex-grow:1;margin:0 auto;position:relative;width:auto}.container.is-fluid{max-width:none!important;padding-left:32px;padding-right:32px;width:100%}@media screen and (min-width:1024px){.container{max-width:960px}}@media screen and (max-width:1215px){.container.is-widescreen:not(.is-max-desktop){max-width:1152px}}@media screen and (max-width:1407px){.container.is-fullhd:not(.is-max-desktop):not(.is-max-widescreen){max-width:1344px}}@media screen and (min-width:1216px){.container:not(.is-max-desktop){max-width:1152px}}@media screen and (min-width:1408px){.container:not(.is-max-desktop):not(.is-max-widescreen){max-width:1344px}}.content li+li{margin-top:.25em}.content blockquote:not(:last-child),.content dl:not(:last-child),.content ol:not(:last-child),.content p:not(:last-child),.content pre:not(:last-child),.content table:not(:last-child),.content ul:not(:last-child){margin-bottom:1em}.content h1,.content h2,.content h3,.content h4,.content h5,.content h6{color:#363636;font-weight:600;line-height:1.125}.content h1{font-size:2em;margin-bottom:.5em}.content h1:not(:first-child){margin-top:1em}.content h2{font-size:1.75em;margin-bottom:.5714em}.content h2:not(:first-child){margin-top:1.1428em}.content h3{font-size:1.5em;margin-bottom:.6666em}.content h3:not(:first-child){margin-top:1.3333em}.content h4{font-size:1.25em;margin-bottom:.8em}.content h5{font-size:1.125em;margin-bottom:.8888em}.content h6{font-size:1em;margin-bottom:1em}.content blockquote{background-color:#f5f5f5;border-left:5px solid #dbdbdb;padding:1.25em 1.5em}.content ol{list-style-position:outside;margin-left:2em;margin-top:1em}.content ol:not([type]){list-style-type:decimal}.content ol:not([type]).is-lower-alpha{list-style-type:lower-alpha}.content ol:not([type]).is-lower-roman{list-style-type:lower-roman}.content ol:not([type]).is-upper-alpha{list-style-type:upper-alpha}.content ol:not([type]).is-upper-roman{list-style-type:upper-roman}.content ul{list-style:disc outside;margin-left:2em;margin-top:1em}.content ul ul{list-style-type:circle;margin-top:.5em}.content ul ul ul{list-style-type:square}.content dd{margin-left:2em}.content figure{margin-left:2em;margin-right:2em;text-align:center}.content figure:not(:first-child){margin-top:2em}.content figure:not(:last-child){margin-bottom:2em}.content figure img{display:inline-block}.content figure figcaption{font-style:italic}.content pre{-webkit-overflow-scrolling:touch;overflow-x:auto;padding:1.25em 1.5em;white-space:pre;word-wrap:normal}.content sub,.content sup{font-size:75%}.content table{width:100%}.content table td,.content table th{border:1px solid #dbdbdb;border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.content table th{color:#363636}.content table th:not([align]){text-align:inherit}.content table thead td,.content table thead th{border-width:0 0 2px;color:#363636}.content table tfoot td,.content table tfoot th{border-width:2px 0 0;color:#363636}.content table tbody tr:last-child td,.content table tbody tr:last-child th{border-bottom-width:0}.content .tabs li+li{margin-top:0}.content.is-small{font-size:.75rem}.content.is-medium{font-size:1.25rem}.content.is-large{font-size:1.5rem}.icon{align-items:center;display:inline-flex;justify-content:center;height:1.5rem;width:1.5rem}.icon.is-small{height:1rem;width:1rem}.icon.is-medium{height:2rem;width:2rem}.icon.is-large{height:3rem;width:3rem}.image{display:block;position:relative}.image img{display:block;height:auto;width:100%}.image img.is-rounded{border-radius:290486px}.image.is-fullwidth{width:100%}.image.is-16by9 .has-ratio,.image.is-16by9 img,.image.is-1by1 .has-ratio,.image.is-1by1 img,.image.is-1by2 .has-ratio,.image.is-1by2 img,.image.is-1by3 .has-ratio,.image.is-1by3 img,.image.is-2by1 .has-ratio,.image.is-2by1 img,.image.is-2by3 .has-ratio,.image.is-2by3 img,.image.is-3by1 .has-ratio,.image.is-3by1 img,.image.is-3by2 .has-ratio,.image.is-3by2 img,.image.is-3by4 .has-ratio,.image.is-3by4 img,.image.is-3by5 .has-ratio,.image.is-3by5 img,.image.is-4by3 .has-ratio,.image.is-4by3 img,.image.is-4by5 .has-ratio,.image.is-4by5 img,.image.is-5by3 .has-ratio,.image.is-5by3 img,.image.is-5by4 .has-ratio,.image.is-5by4 img,.image.is-9by16 .has-ratio,.image.is-9by16 img,.image.is-square .has-ratio,.image.is-square img{height:100%;width:100%}.image.is-1by1,.image.is-square{padding-top:100%}.image.is-5by4{padding-top:80%}.image.is-4by3{padding-top:75%}.image.is-3by2{padding-top:66.6666%}.image.is-5by3{padding-top:60%}.image.is-16by9{padding-top:56.25%}.image.is-2by1{padding-top:50%}.image.is-3by1{padding-top:33.3333%}.image.is-4by5{padding-top:125%}.image.is-3by4{padding-top:133.3333%}.image.is-2by3{padding-top:150%}.image.is-3by5{padding-top:166.6666%}.image.is-9by16{padding-top:177.7777%}.image.is-1by2{padding-top:200%}.image.is-1by3{padding-top:300%}.image.is-16x16{height:16px;width:16px}.image.is-24x24{height:24px;width:24px}.image.is-32x32{height:32px;width:32px}.image.is-48x48{height:48px;width:48px}.image.is-64x64{height:64px;width:64px}.image.is-96x96{height:96px;width:96px}.image.is-128x128{height:128px;width:128px}.notification{background-color:#f5f5f5;border-radius:4px;position:relative;padding:1.25rem 2.5rem 1.25rem 1.5rem}.notification a:not(.button):not(.dropdown-item){color:currentColor;text-decoration:underline}.notification strong{color:currentColor}.notification code,.notification pre{background:#fff}.notification pre code{background:0 0}.notification>.delete{right:.5rem;position:absolute;top:.5rem}.notification .content,.notification .subtitle,.notification .title{color:currentColor}.notification.is-white{background-color:#fff;color:#0a0a0a}.notification.is-black{background-color:#0a0a0a;color:#fff}.notification.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.notification.is-dark{background-color:#363636;color:#fff}.notification.is-primary{background-color:#00d1b2;color:#fff}.notification.is-primary.is-light{background-color:#ebfffc;color:#00947e}.notification.is-link{background-color:#3273dc;color:#fff}.notification.is-link.is-light{background-color:#eef3fc;color:#2160c4}.notification.is-info{background-color:#3298dc;color:#fff}.notification.is-info.is-light{background-color:#eef6fc;color:#1d72aa}.notification.is-success{background-color:#48c774;color:#fff}.notification.is-success.is-light{background-color:#effaf3;color:#257942}.notification.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.notification.is-warning.is-light{background-color:#fffbeb;color:#947600}.notification.is-danger{background-color:#f14668;color:#fff}.notification.is-danger.is-light{background-color:#feecf0;color:#cc0f35}.progress{-moz-appearance:none;-webkit-appearance:none;border:none;border-radius:290486px;display:block;height:1rem;overflow:hidden;padding:0;width:100%}.progress::-webkit-progress-bar{background-color:#ededed}.progress::-webkit-progress-value{background-color:#4a4a4a}.progress::-moz-progress-bar{background-color:#4a4a4a}.progress::-ms-fill{background-color:#4a4a4a;border:none}.progress.is-white::-webkit-progress-value{background-color:#fff}.progress.is-white::-moz-progress-bar{background-color:#fff}.progress.is-white::-ms-fill{background-color:#fff}.progress.is-white:indeterminate{background-image:linear-gradient(to right,#fff 30%,#ededed 30%)}.progress.is-black::-webkit-progress-value{background-color:#0a0a0a}.progress.is-black::-moz-progress-bar{background-color:#0a0a0a}.progress.is-black::-ms-fill{background-color:#0a0a0a}.progress.is-black:indeterminate{background-image:linear-gradient(to right,#0a0a0a 30%,#ededed 30%)}.progress.is-light::-webkit-progress-value{background-color:#f5f5f5}.progress.is-light::-moz-progress-bar{background-color:#f5f5f5}.progress.is-light::-ms-fill{background-color:#f5f5f5}.progress.is-light:indeterminate{background-image:linear-gradient(to right,#f5f5f5 30%,#ededed 30%)}.progress.is-dark::-webkit-progress-value{background-color:#363636}.progress.is-dark::-moz-progress-bar{background-color:#363636}.progress.is-dark::-ms-fill{background-color:#363636}.progress.is-dark:indeterminate{background-image:linear-gradient(to right,#363636 30%,#ededed 30%)}.progress.is-primary::-webkit-progress-value{background-color:#00d1b2}.progress.is-primary::-moz-progress-bar{background-color:#00d1b2}.progress.is-primary::-ms-fill{background-color:#00d1b2}.progress.is-primary:indeterminate{background-image:linear-gradient(to right,#00d1b2 30%,#ededed 30%)}.progress.is-link::-webkit-progress-value{background-color:#3273dc}.progress.is-link::-moz-progress-bar{background-color:#3273dc}.progress.is-link::-ms-fill{background-color:#3273dc}.progress.is-link:indeterminate{background-image:linear-gradient(to right,#3273dc 30%,#ededed 30%)}.progress.is-info::-webkit-progress-value{background-color:#3298dc}.progress.is-info::-moz-progress-bar{background-color:#3298dc}.progress.is-info::-ms-fill{background-color:#3298dc}.progress.is-info:indeterminate{background-image:linear-gradient(to right,#3298dc 30%,#ededed 30%)}.progress.is-success::-webkit-progress-value{background-color:#48c774}.progress.is-success::-moz-progress-bar{background-color:#48c774}.progress.is-success::-ms-fill{background-color:#48c774}.progress.is-success:indeterminate{background-image:linear-gradient(to right,#48c774 30%,#ededed 30%)}.progress.is-warning::-webkit-progress-value{background-color:#ffdd57}.progress.is-warning::-moz-progress-bar{background-color:#ffdd57}.progress.is-warning::-ms-fill{background-color:#ffdd57}.progress.is-warning:indeterminate{background-image:linear-gradient(to right,#ffdd57 30%,#ededed 30%)}.progress.is-danger::-webkit-progress-value{background-color:#f14668}.progress.is-danger::-moz-progress-bar{background-color:#f14668}.progress.is-danger::-ms-fill{background-color:#f14668}.progress.is-danger:indeterminate{background-image:linear-gradient(to right,#f14668 30%,#ededed 30%)}.progress:indeterminate{-webkit-animation-duration:1.5s;animation-duration:1.5s;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite;-webkit-animation-name:moveIndeterminate;animation-name:moveIndeterminate;-webkit-animation-timing-function:linear;animation-timing-function:linear;background-color:#ededed;background-image:linear-gradient(to right,#4a4a4a 30%,#ededed 30%);background-position:top left;background-repeat:no-repeat;background-size:150% 150%}.progress:indeterminate::-webkit-progress-bar{background-color:transparent}.progress:indeterminate::-moz-progress-bar{background-color:transparent}.progress:indeterminate::-ms-fill{animation-name:none}.progress.is-small{height:.75rem}.progress.is-medium{height:1.25rem}.progress.is-large{height:1.5rem}@-webkit-keyframes moveIndeterminate{from{background-position:200% 0}to{background-position:-200% 0}}@keyframes moveIndeterminate{from{background-position:200% 0}to{background-position:-200% 0}}.table{background-color:#fff;color:#363636}.table td,.table th{border:1px solid #dbdbdb;border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.table td.is-white,.table th.is-white{background-color:#fff;border-color:#fff;color:#0a0a0a}.table td.is-black,.table th.is-black{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.table td.is-light,.table th.is-light{background-color:#f5f5f5;border-color:#f5f5f5;color:rgba(0,0,0,.7)}.table td.is-dark,.table th.is-dark{background-color:#363636;border-color:#363636;color:#fff}.table td.is-primary,.table th.is-primary{background-color:#00d1b2;border-color:#00d1b2;color:#fff}.table td.is-link,.table th.is-link{background-color:#3273dc;border-color:#3273dc;color:#fff}.table td.is-info,.table th.is-info{background-color:#3298dc;border-color:#3298dc;color:#fff}.table td.is-success,.table th.is-success{background-color:#48c774;border-color:#48c774;color:#fff}.table td.is-warning,.table th.is-warning{background-color:#ffdd57;border-color:#ffdd57;color:rgba(0,0,0,.7)}.table td.is-danger,.table th.is-danger{background-color:#f14668;border-color:#f14668;color:#fff}.table td.is-narrow,.table th.is-narrow{white-space:nowrap;width:1%}.table td.is-selected,.table th.is-selected{background-color:#00d1b2;color:#fff}.table td.is-selected a,.table td.is-selected strong,.table th.is-selected a,.table th.is-selected strong{color:currentColor}.table td.is-vcentered,.table th.is-vcentered{vertical-align:middle}.table th{color:#363636}.table th:not([align]){text-align:inherit}.table tr.is-selected{background-color:#00d1b2;color:#fff}.table tr.is-selected a,.table tr.is-selected strong{color:currentColor}.table tr.is-selected td,.table tr.is-selected th{border-color:#fff;color:currentColor}.table thead{background-color:transparent}.table thead td,.table thead th{border-width:0 0 2px;color:#363636}.table tfoot{background-color:transparent}.table tfoot td,.table tfoot th{border-width:2px 0 0;color:#363636}.table tbody{background-color:transparent}.table tbody tr:last-child td,.table tbody tr:last-child th{border-bottom-width:0}.table.is-bordered td,.table.is-bordered th{border-width:1px}.table.is-bordered tr:last-child td,.table.is-bordered tr:last-child th{border-bottom-width:1px}.table.is-fullwidth{width:100%}.table.is-hoverable tbody tr:not(.is-selected):hover{background-color:#fafafa}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover{background-color:#fafafa}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover:nth-child(even){background-color:#f5f5f5}.table.is-narrow td,.table.is-narrow th{padding:.25em .5em}.table.is-striped tbody tr:not(.is-selected):nth-child(even){background-color:#fafafa}.table-container{-webkit-overflow-scrolling:touch;overflow:auto;overflow-y:hidden;max-width:100%}.tags{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.tags .tag{margin-bottom:.5rem}.tags .tag:not(:last-child){margin-right:.5rem}.tags:last-child{margin-bottom:-.5rem}.tags:not(:last-child){margin-bottom:1rem}.tags.are-medium .tag:not(.is-normal):not(.is-large){font-size:1rem}.tags.are-large .tag:not(.is-normal):not(.is-medium){font-size:1.25rem}.tags.is-centered{justify-content:center}.tags.is-centered .tag{margin-right:.25rem;margin-left:.25rem}.tags.is-right{justify-content:flex-end}.tags.is-right .tag:not(:first-child){margin-left:.5rem}.tags.is-right .tag:not(:last-child){margin-right:0}.tags.has-addons .tag{margin-right:0}.tags.has-addons .tag:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}.tags.has-addons .tag:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.tag:not(body){align-items:center;background-color:#f5f5f5;border-radius:4px;color:#4a4a4a;display:inline-flex;font-size:.75rem;height:2em;justify-content:center;line-height:1.5;padding-left:.75em;padding-right:.75em;white-space:nowrap}.tag:not(body) .delete{margin-left:.25rem;margin-right:-.375rem}.tag:not(body).is-white{background-color:#fff;color:#0a0a0a}.tag:not(body).is-black{background-color:#0a0a0a;color:#fff}.tag:not(body).is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.tag:not(body).is-dark{background-color:#363636;color:#fff}.tag:not(body).is-primary{background-color:#00d1b2;color:#fff}.tag:not(body).is-primary.is-light{background-color:#ebfffc;color:#00947e}.tag:not(body).is-link{background-color:#3273dc;color:#fff}.tag:not(body).is-link.is-light{background-color:#eef3fc;color:#2160c4}.tag:not(body).is-info{background-color:#3298dc;color:#fff}.tag:not(body).is-info.is-light{background-color:#eef6fc;color:#1d72aa}.tag:not(body).is-success{background-color:#48c774;color:#fff}.tag:not(body).is-success.is-light{background-color:#effaf3;color:#257942}.tag:not(body).is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.tag:not(body).is-warning.is-light{background-color:#fffbeb;color:#947600}.tag:not(body).is-danger{background-color:#f14668;color:#fff}.tag:not(body).is-danger.is-light{background-color:#feecf0;color:#cc0f35}.tag:not(body).is-normal{font-size:.75rem}.tag:not(body).is-medium{font-size:1rem}.tag:not(body).is-large{font-size:1.25rem}.tag:not(body) .icon:first-child:not(:last-child){margin-left:-.375em;margin-right:.1875em}.tag:not(body) .icon:last-child:not(:first-child){margin-left:.1875em;margin-right:-.375em}.tag:not(body) .icon:first-child:last-child{margin-left:-.375em;margin-right:-.375em}.tag:not(body).is-delete{margin-left:1px;padding:0;position:relative;width:2em}.tag:not(body).is-delete::after,.tag:not(body).is-delete::before{background-color:currentColor;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.tag:not(body).is-delete::before{height:1px;width:50%}.tag:not(body).is-delete::after{height:50%;width:1px}.tag:not(body).is-delete:focus,.tag:not(body).is-delete:hover{background-color:#e8e8e8}.tag:not(body).is-delete:active{background-color:#dbdbdb}.tag:not(body).is-rounded{border-radius:290486px}a.tag:hover{text-decoration:underline}.subtitle,.title{word-break:break-word}.subtitle em,.subtitle span,.title em,.title span{font-weight:inherit}.subtitle sub,.title sub{font-size:.75em}.subtitle sup,.title sup{font-size:.75em}.subtitle .tag,.title .tag{vertical-align:middle}.title{color:#363636;font-size:2rem;font-weight:600;line-height:1.125}.title strong{color:inherit;font-weight:inherit}.title+.highlight{margin-top:-.75rem}.title:not(.is-spaced)+.subtitle{margin-top:-1.25rem}.title.is-1{font-size:3rem}.title.is-2{font-size:2.5rem}.title.is-3{font-size:2rem}.title.is-4{font-size:1.5rem}.title.is-5{font-size:1.25rem}.title.is-6{font-size:1rem}.title.is-7{font-size:.75rem}.subtitle{color:#4a4a4a;font-size:1.25rem;font-weight:400;line-height:1.25}.subtitle strong{color:#363636;font-weight:600}.subtitle:not(.is-spaced)+.title{margin-top:-1.25rem}.subtitle.is-1{font-size:3rem}.subtitle.is-2{font-size:2.5rem}.subtitle.is-3{font-size:2rem}.subtitle.is-4{font-size:1.5rem}.subtitle.is-5{font-size:1.25rem}.subtitle.is-6{font-size:1rem}.subtitle.is-7{font-size:.75rem}.heading{display:block;font-size:11px;letter-spacing:1px;margin-bottom:5px;text-transform:uppercase}.highlight{font-weight:400;max-width:100%;overflow:hidden;padding:0}.highlight pre{overflow:auto;max-width:100%}.number{align-items:center;background-color:#f5f5f5;border-radius:290486px;display:inline-flex;font-size:1.25rem;height:2em;justify-content:center;margin-right:1.5rem;min-width:2.5em;padding:.25rem .5rem;text-align:center;vertical-align:top}.input,.select select,.textarea{background-color:#fff;border-color:#dbdbdb;border-radius:4px;color:#363636}.input::-moz-placeholder,.select select::-moz-placeholder,.textarea::-moz-placeholder{color:rgba(54,54,54,.3)}.input::-webkit-input-placeholder,.select select::-webkit-input-placeholder,.textarea::-webkit-input-placeholder{color:rgba(54,54,54,.3)}.input:-moz-placeholder,.select select:-moz-placeholder,.textarea:-moz-placeholder{color:rgba(54,54,54,.3)}.input:-ms-input-placeholder,.select select:-ms-input-placeholder,.textarea:-ms-input-placeholder{color:rgba(54,54,54,.3)}.input:hover,.is-hovered.input,.is-hovered.textarea,.select select.is-hovered,.select select:hover,.textarea:hover{border-color:#b5b5b5}.input:active,.input:focus,.is-active.input,.is-active.textarea,.is-focused.input,.is-focused.textarea,.select select.is-active,.select select.is-focused,.select select:active,.select select:focus,.textarea:active,.textarea:focus{border-color:#3273dc;box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.input[disabled],.select fieldset[disabled] select,.select select[disabled],.textarea[disabled],fieldset[disabled] .input,fieldset[disabled] .select select,fieldset[disabled] .textarea{background-color:#f5f5f5;border-color:#f5f5f5;box-shadow:none;color:#7a7a7a}.input[disabled]::-moz-placeholder,.select fieldset[disabled] select::-moz-placeholder,.select select[disabled]::-moz-placeholder,.textarea[disabled]::-moz-placeholder,fieldset[disabled] .input::-moz-placeholder,fieldset[disabled] .select select::-moz-placeholder,fieldset[disabled] .textarea::-moz-placeholder{color:rgba(122,122,122,.3)}.input[disabled]::-webkit-input-placeholder,.select fieldset[disabled] select::-webkit-input-placeholder,.select select[disabled]::-webkit-input-placeholder,.textarea[disabled]::-webkit-input-placeholder,fieldset[disabled] .input::-webkit-input-placeholder,fieldset[disabled] .select select::-webkit-input-placeholder,fieldset[disabled] .textarea::-webkit-input-placeholder{color:rgba(122,122,122,.3)}.input[disabled]:-moz-placeholder,.select fieldset[disabled] select:-moz-placeholder,.select select[disabled]:-moz-placeholder,.textarea[disabled]:-moz-placeholder,fieldset[disabled] .input:-moz-placeholder,fieldset[disabled] .select select:-moz-placeholder,fieldset[disabled] .textarea:-moz-placeholder{color:rgba(122,122,122,.3)}.input[disabled]:-ms-input-placeholder,.select fieldset[disabled] select:-ms-input-placeholder,.select select[disabled]:-ms-input-placeholder,.textarea[disabled]:-ms-input-placeholder,fieldset[disabled] .input:-ms-input-placeholder,fieldset[disabled] .select select:-ms-input-placeholder,fieldset[disabled] .textarea:-ms-input-placeholder{color:rgba(122,122,122,.3)}.input,.textarea{box-shadow:inset 0 .0625em .125em rgba(10,10,10,.05);max-width:100%;width:100%}.input[readonly],.textarea[readonly]{box-shadow:none}.is-white.input,.is-white.textarea{border-color:#fff}.is-white.input:active,.is-white.input:focus,.is-white.is-active.input,.is-white.is-active.textarea,.is-white.is-focused.input,.is-white.is-focused.textarea,.is-white.textarea:active,.is-white.textarea:focus{box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.is-black.input,.is-black.textarea{border-color:#0a0a0a}.is-black.input:active,.is-black.input:focus,.is-black.is-active.input,.is-black.is-active.textarea,.is-black.is-focused.input,.is-black.is-focused.textarea,.is-black.textarea:active,.is-black.textarea:focus{box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.is-light.input,.is-light.textarea{border-color:#f5f5f5}.is-light.input:active,.is-light.input:focus,.is-light.is-active.input,.is-light.is-active.textarea,.is-light.is-focused.input,.is-light.is-focused.textarea,.is-light.textarea:active,.is-light.textarea:focus{box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.is-dark.input,.is-dark.textarea{border-color:#363636}.is-dark.input:active,.is-dark.input:focus,.is-dark.is-active.input,.is-dark.is-active.textarea,.is-dark.is-focused.input,.is-dark.is-focused.textarea,.is-dark.textarea:active,.is-dark.textarea:focus{box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.is-primary.input,.is-primary.textarea{border-color:#00d1b2}.is-primary.input:active,.is-primary.input:focus,.is-primary.is-active.input,.is-primary.is-active.textarea,.is-primary.is-focused.input,.is-primary.is-focused.textarea,.is-primary.textarea:active,.is-primary.textarea:focus{box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.is-link.input,.is-link.textarea{border-color:#3273dc}.is-link.input:active,.is-link.input:focus,.is-link.is-active.input,.is-link.is-active.textarea,.is-link.is-focused.input,.is-link.is-focused.textarea,.is-link.textarea:active,.is-link.textarea:focus{box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.is-info.input,.is-info.textarea{border-color:#3298dc}.is-info.input:active,.is-info.input:focus,.is-info.is-active.input,.is-info.is-active.textarea,.is-info.is-focused.input,.is-info.is-focused.textarea,.is-info.textarea:active,.is-info.textarea:focus{box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.is-success.input,.is-success.textarea{border-color:#48c774}.is-success.input:active,.is-success.input:focus,.is-success.is-active.input,.is-success.is-active.textarea,.is-success.is-focused.input,.is-success.is-focused.textarea,.is-success.textarea:active,.is-success.textarea:focus{box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.is-warning.input,.is-warning.textarea{border-color:#ffdd57}.is-warning.input:active,.is-warning.input:focus,.is-warning.is-active.input,.is-warning.is-active.textarea,.is-warning.is-focused.input,.is-warning.is-focused.textarea,.is-warning.textarea:active,.is-warning.textarea:focus{box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.is-danger.input,.is-danger.textarea{border-color:#f14668}.is-danger.input:active,.is-danger.input:focus,.is-danger.is-active.input,.is-danger.is-active.textarea,.is-danger.is-focused.input,.is-danger.is-focused.textarea,.is-danger.textarea:active,.is-danger.textarea:focus{box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.is-small.input,.is-small.textarea{border-radius:2px;font-size:.75rem}.is-medium.input,.is-medium.textarea{font-size:1.25rem}.is-large.input,.is-large.textarea{font-size:1.5rem}.is-fullwidth.input,.is-fullwidth.textarea{display:block;width:100%}.is-inline.input,.is-inline.textarea{display:inline;width:auto}.input.is-rounded{border-radius:290486px;padding-left:calc(calc(.75em - 1px) + .375em);padding-right:calc(calc(.75em - 1px) + .375em)}.input.is-static{background-color:transparent;border-color:transparent;box-shadow:none;padding-left:0;padding-right:0}.textarea{display:block;max-width:100%;min-width:100%;padding:calc(.75em - 1px);resize:vertical}.textarea:not([rows]){max-height:40em;min-height:8em}.textarea[rows]{height:initial}.textarea.has-fixed-size{resize:none}.checkbox,.radio{cursor:pointer;display:inline-block;line-height:1.25;position:relative}.checkbox input,.radio input{cursor:pointer}.checkbox:hover,.radio:hover{color:#363636}.checkbox input[disabled],.checkbox[disabled],.radio input[disabled],.radio[disabled],fieldset[disabled] .checkbox,fieldset[disabled] .radio{color:#7a7a7a;cursor:not-allowed}.radio+.radio{margin-left:.5em}.select{display:inline-block;max-width:100%;position:relative;vertical-align:top}.select:not(.is-multiple){height:2.5em}.select:not(.is-multiple):not(.is-loading)::after{border-color:#3273dc;right:1.125em;z-index:4}.select.is-rounded select{border-radius:290486px;padding-left:1em}.select select{cursor:pointer;display:block;font-size:1em;max-width:100%;outline:0}.select select::-ms-expand{display:none}.select select[disabled]:hover,fieldset[disabled] .select select:hover{border-color:#f5f5f5}.select select:not([multiple]){padding-right:2.5em}.select select[multiple]{height:auto;padding:0}.select select[multiple] option{padding:.5em 1em}.select:not(.is-multiple):not(.is-loading):hover::after{border-color:#363636}.select.is-white:not(:hover)::after{border-color:#fff}.select.is-white select{border-color:#fff}.select.is-white select.is-hovered,.select.is-white select:hover{border-color:#f2f2f2}.select.is-white select.is-active,.select.is-white select.is-focused,.select.is-white select:active,.select.is-white select:focus{box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.select.is-black:not(:hover)::after{border-color:#0a0a0a}.select.is-black select{border-color:#0a0a0a}.select.is-black select.is-hovered,.select.is-black select:hover{border-color:#000}.select.is-black select.is-active,.select.is-black select.is-focused,.select.is-black select:active,.select.is-black select:focus{box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.select.is-light:not(:hover)::after{border-color:#f5f5f5}.select.is-light select{border-color:#f5f5f5}.select.is-light select.is-hovered,.select.is-light select:hover{border-color:#e8e8e8}.select.is-light select.is-active,.select.is-light select.is-focused,.select.is-light select:active,.select.is-light select:focus{box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.select.is-dark:not(:hover)::after{border-color:#363636}.select.is-dark select{border-color:#363636}.select.is-dark select.is-hovered,.select.is-dark select:hover{border-color:#292929}.select.is-dark select.is-active,.select.is-dark select.is-focused,.select.is-dark select:active,.select.is-dark select:focus{box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.select.is-primary:not(:hover)::after{border-color:#00d1b2}.select.is-primary select{border-color:#00d1b2}.select.is-primary select.is-hovered,.select.is-primary select:hover{border-color:#00b89c}.select.is-primary select.is-active,.select.is-primary select.is-focused,.select.is-primary select:active,.select.is-primary select:focus{box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.select.is-link:not(:hover)::after{border-color:#3273dc}.select.is-link select{border-color:#3273dc}.select.is-link select.is-hovered,.select.is-link select:hover{border-color:#2366d1}.select.is-link select.is-active,.select.is-link select.is-focused,.select.is-link select:active,.select.is-link select:focus{box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.select.is-info:not(:hover)::after{border-color:#3298dc}.select.is-info select{border-color:#3298dc}.select.is-info select.is-hovered,.select.is-info select:hover{border-color:#238cd1}.select.is-info select.is-active,.select.is-info select.is-focused,.select.is-info select:active,.select.is-info select:focus{box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.select.is-success:not(:hover)::after{border-color:#48c774}.select.is-success select{border-color:#48c774}.select.is-success select.is-hovered,.select.is-success select:hover{border-color:#3abb67}.select.is-success select.is-active,.select.is-success select.is-focused,.select.is-success select:active,.select.is-success select:focus{box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.select.is-warning:not(:hover)::after{border-color:#ffdd57}.select.is-warning select{border-color:#ffdd57}.select.is-warning select.is-hovered,.select.is-warning select:hover{border-color:#ffd83d}.select.is-warning select.is-active,.select.is-warning select.is-focused,.select.is-warning select:active,.select.is-warning select:focus{box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.select.is-danger:not(:hover)::after{border-color:#f14668}.select.is-danger select{border-color:#f14668}.select.is-danger select.is-hovered,.select.is-danger select:hover{border-color:#ef2e55}.select.is-danger select.is-active,.select.is-danger select.is-focused,.select.is-danger select:active,.select.is-danger select:focus{box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.select.is-small{border-radius:2px;font-size:.75rem}.select.is-medium{font-size:1.25rem}.select.is-large{font-size:1.5rem}.select.is-disabled::after{border-color:#7a7a7a}.select.is-fullwidth{width:100%}.select.is-fullwidth select{width:100%}.select.is-loading::after{margin-top:0;position:absolute;right:.625em;top:.625em;transform:none}.select.is-loading.is-small:after{font-size:.75rem}.select.is-loading.is-medium:after{font-size:1.25rem}.select.is-loading.is-large:after{font-size:1.5rem}.file{align-items:stretch;display:flex;justify-content:flex-start;position:relative}.file.is-white .file-cta{background-color:#fff;border-color:transparent;color:#0a0a0a}.file.is-white.is-hovered .file-cta,.file.is-white:hover .file-cta{background-color:#f9f9f9;border-color:transparent;color:#0a0a0a}.file.is-white.is-focused .file-cta,.file.is-white:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(255,255,255,.25);color:#0a0a0a}.file.is-white.is-active .file-cta,.file.is-white:active .file-cta{background-color:#f2f2f2;border-color:transparent;color:#0a0a0a}.file.is-black .file-cta{background-color:#0a0a0a;border-color:transparent;color:#fff}.file.is-black.is-hovered .file-cta,.file.is-black:hover .file-cta{background-color:#040404;border-color:transparent;color:#fff}.file.is-black.is-focused .file-cta,.file.is-black:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(10,10,10,.25);color:#fff}.file.is-black.is-active .file-cta,.file.is-black:active .file-cta{background-color:#000;border-color:transparent;color:#fff}.file.is-light .file-cta{background-color:#f5f5f5;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-light.is-hovered .file-cta,.file.is-light:hover .file-cta{background-color:#eee;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-light.is-focused .file-cta,.file.is-light:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(245,245,245,.25);color:rgba(0,0,0,.7)}.file.is-light.is-active .file-cta,.file.is-light:active .file-cta{background-color:#e8e8e8;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-dark .file-cta{background-color:#363636;border-color:transparent;color:#fff}.file.is-dark.is-hovered .file-cta,.file.is-dark:hover .file-cta{background-color:#2f2f2f;border-color:transparent;color:#fff}.file.is-dark.is-focused .file-cta,.file.is-dark:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(54,54,54,.25);color:#fff}.file.is-dark.is-active .file-cta,.file.is-dark:active .file-cta{background-color:#292929;border-color:transparent;color:#fff}.file.is-primary .file-cta{background-color:#00d1b2;border-color:transparent;color:#fff}.file.is-primary.is-hovered .file-cta,.file.is-primary:hover .file-cta{background-color:#00c4a7;border-color:transparent;color:#fff}.file.is-primary.is-focused .file-cta,.file.is-primary:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(0,209,178,.25);color:#fff}.file.is-primary.is-active .file-cta,.file.is-primary:active .file-cta{background-color:#00b89c;border-color:transparent;color:#fff}.file.is-link .file-cta{background-color:#3273dc;border-color:transparent;color:#fff}.file.is-link.is-hovered .file-cta,.file.is-link:hover .file-cta{background-color:#276cda;border-color:transparent;color:#fff}.file.is-link.is-focused .file-cta,.file.is-link:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(50,115,220,.25);color:#fff}.file.is-link.is-active .file-cta,.file.is-link:active .file-cta{background-color:#2366d1;border-color:transparent;color:#fff}.file.is-info .file-cta{background-color:#3298dc;border-color:transparent;color:#fff}.file.is-info.is-hovered .file-cta,.file.is-info:hover .file-cta{background-color:#2793da;border-color:transparent;color:#fff}.file.is-info.is-focused .file-cta,.file.is-info:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(50,152,220,.25);color:#fff}.file.is-info.is-active .file-cta,.file.is-info:active .file-cta{background-color:#238cd1;border-color:transparent;color:#fff}.file.is-success .file-cta{background-color:#48c774;border-color:transparent;color:#fff}.file.is-success.is-hovered .file-cta,.file.is-success:hover .file-cta{background-color:#3ec46d;border-color:transparent;color:#fff}.file.is-success.is-focused .file-cta,.file.is-success:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(72,199,116,.25);color:#fff}.file.is-success.is-active .file-cta,.file.is-success:active .file-cta{background-color:#3abb67;border-color:transparent;color:#fff}.file.is-warning .file-cta{background-color:#ffdd57;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-warning.is-hovered .file-cta,.file.is-warning:hover .file-cta{background-color:#ffdb4a;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-warning.is-focused .file-cta,.file.is-warning:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(255,221,87,.25);color:rgba(0,0,0,.7)}.file.is-warning.is-active .file-cta,.file.is-warning:active .file-cta{background-color:#ffd83d;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-danger .file-cta{background-color:#f14668;border-color:transparent;color:#fff}.file.is-danger.is-hovered .file-cta,.file.is-danger:hover .file-cta{background-color:#f03a5f;border-color:transparent;color:#fff}.file.is-danger.is-focused .file-cta,.file.is-danger:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(241,70,104,.25);color:#fff}.file.is-danger.is-active .file-cta,.file.is-danger:active .file-cta{background-color:#ef2e55;border-color:transparent;color:#fff}.file.is-small{font-size:.75rem}.file.is-medium{font-size:1.25rem}.file.is-medium .file-icon .fa{font-size:21px}.file.is-large{font-size:1.5rem}.file.is-large .file-icon .fa{font-size:28px}.file.has-name .file-cta{border-bottom-right-radius:0;border-top-right-radius:0}.file.has-name .file-name{border-bottom-left-radius:0;border-top-left-radius:0}.file.has-name.is-empty .file-cta{border-radius:4px}.file.has-name.is-empty .file-name{display:none}.file.is-boxed .file-label{flex-direction:column}.file.is-boxed .file-cta{flex-direction:column;height:auto;padding:1em 3em}.file.is-boxed .file-name{border-width:0 1px 1px}.file.is-boxed .file-icon{height:1.5em;width:1.5em}.file.is-boxed .file-icon .fa{font-size:21px}.file.is-boxed.is-small .file-icon .fa{font-size:14px}.file.is-boxed.is-medium .file-icon .fa{font-size:28px}.file.is-boxed.is-large .file-icon .fa{font-size:35px}.file.is-boxed.has-name .file-cta{border-radius:4px 4px 0 0}.file.is-boxed.has-name .file-name{border-radius:0 0 4px 4px;border-width:0 1px 1px}.file.is-centered{justify-content:center}.file.is-fullwidth .file-label{width:100%}.file.is-fullwidth .file-name{flex-grow:1;max-width:none}.file.is-right{justify-content:flex-end}.file.is-right .file-cta{border-radius:0 4px 4px 0}.file.is-right .file-name{border-radius:4px 0 0 4px;border-width:1px 0 1px 1px;order:-1}.file-label{align-items:stretch;display:flex;cursor:pointer;justify-content:flex-start;overflow:hidden;position:relative}.file-label:hover .file-cta{background-color:#eee;color:#363636}.file-label:hover .file-name{border-color:#d5d5d5}.file-label:active .file-cta{background-color:#e8e8e8;color:#363636}.file-label:active .file-name{border-color:#cfcfcf}.file-input{height:100%;left:0;opacity:0;outline:0;position:absolute;top:0;width:100%}.file-cta,.file-name{border-color:#dbdbdb;border-radius:4px;font-size:1em;padding-left:1em;padding-right:1em;white-space:nowrap}.file-cta{background-color:#f5f5f5;color:#4a4a4a}.file-name{border-color:#dbdbdb;border-style:solid;border-width:1px 1px 1px 0;display:block;max-width:16em;overflow:hidden;text-align:inherit;text-overflow:ellipsis}.file-icon{align-items:center;display:flex;height:1em;justify-content:center;margin-right:.5em;width:1em}.file-icon .fa{font-size:14px}.label{color:#363636;display:block;font-size:1rem;font-weight:700}.label:not(:last-child){margin-bottom:.5em}.label.is-small{font-size:.75rem}.label.is-medium{font-size:1.25rem}.label.is-large{font-size:1.5rem}.help{display:block;font-size:.75rem;margin-top:.25rem}.help.is-white{color:#fff}.help.is-black{color:#0a0a0a}.help.is-light{color:#f5f5f5}.help.is-dark{color:#363636}.help.is-primary{color:#00d1b2}.help.is-link{color:#3273dc}.help.is-info{color:#3298dc}.help.is-success{color:#48c774}.help.is-warning{color:#ffdd57}.help.is-danger{color:#f14668}.field:not(:last-child){margin-bottom:.75rem}.field.has-addons{display:flex;justify-content:flex-start}.field.has-addons .control:not(:last-child){margin-right:-1px}.field.has-addons .control:not(:first-child):not(:last-child) .button,.field.has-addons .control:not(:first-child):not(:last-child) .input,.field.has-addons .control:not(:first-child):not(:last-child) .select select{border-radius:0}.field.has-addons .control:first-child:not(:only-child) .button,.field.has-addons .control:first-child:not(:only-child) .input,.field.has-addons .control:first-child:not(:only-child) .select select{border-bottom-right-radius:0;border-top-right-radius:0}.field.has-addons .control:last-child:not(:only-child) .button,.field.has-addons .control:last-child:not(:only-child) .input,.field.has-addons .control:last-child:not(:only-child) .select select{border-bottom-left-radius:0;border-top-left-radius:0}.field.has-addons .control .button:not([disabled]).is-hovered,.field.has-addons .control .button:not([disabled]):hover,.field.has-addons .control .input:not([disabled]).is-hovered,.field.has-addons .control .input:not([disabled]):hover,.field.has-addons .control .select select:not([disabled]).is-hovered,.field.has-addons .control .select select:not([disabled]):hover{z-index:2}.field.has-addons .control .button:not([disabled]).is-active,.field.has-addons .control .button:not([disabled]).is-focused,.field.has-addons .control .button:not([disabled]):active,.field.has-addons .control .button:not([disabled]):focus,.field.has-addons .control .input:not([disabled]).is-active,.field.has-addons .control .input:not([disabled]).is-focused,.field.has-addons .control .input:not([disabled]):active,.field.has-addons .control .input:not([disabled]):focus,.field.has-addons .control .select select:not([disabled]).is-active,.field.has-addons .control .select select:not([disabled]).is-focused,.field.has-addons .control .select select:not([disabled]):active,.field.has-addons .control .select select:not([disabled]):focus{z-index:3}.field.has-addons .control .button:not([disabled]).is-active:hover,.field.has-addons .control .button:not([disabled]).is-focused:hover,.field.has-addons .control .button:not([disabled]):active:hover,.field.has-addons .control .button:not([disabled]):focus:hover,.field.has-addons .control .input:not([disabled]).is-active:hover,.field.has-addons .control .input:not([disabled]).is-focused:hover,.field.has-addons .control .input:not([disabled]):active:hover,.field.has-addons .control .input:not([disabled]):focus:hover,.field.has-addons .control .select select:not([disabled]).is-active:hover,.field.has-addons .control .select select:not([disabled]).is-focused:hover,.field.has-addons .control .select select:not([disabled]):active:hover,.field.has-addons .control .select select:not([disabled]):focus:hover{z-index:4}.field.has-addons .control.is-expanded{flex-grow:1;flex-shrink:1}.field.has-addons.has-addons-centered{justify-content:center}.field.has-addons.has-addons-right{justify-content:flex-end}.field.has-addons.has-addons-fullwidth .control{flex-grow:1;flex-shrink:0}.field.is-grouped{display:flex;justify-content:flex-start}.field.is-grouped>.control{flex-shrink:0}.field.is-grouped>.control:not(:last-child){margin-bottom:0;margin-right:.75rem}.field.is-grouped>.control.is-expanded{flex-grow:1;flex-shrink:1}.field.is-grouped.is-grouped-centered{justify-content:center}.field.is-grouped.is-grouped-right{justify-content:flex-end}.field.is-grouped.is-grouped-multiline{flex-wrap:wrap}.field.is-grouped.is-grouped-multiline>.control:last-child,.field.is-grouped.is-grouped-multiline>.control:not(:last-child){margin-bottom:.75rem}.field.is-grouped.is-grouped-multiline:last-child{margin-bottom:-.75rem}.field.is-grouped.is-grouped-multiline:not(:last-child){margin-bottom:0}@media screen and (min-width:769px),print{.field.is-horizontal{display:flex}}.field-label .label{font-size:inherit}@media screen and (max-width:768px){.field-label{margin-bottom:.5rem}}@media screen and (min-width:769px),print{.field-label{flex-basis:0;flex-grow:1;flex-shrink:0;margin-right:1.5rem;text-align:right}.field-label.is-small{font-size:.75rem;padding-top:.375em}.field-label.is-normal{padding-top:.375em}.field-label.is-medium{font-size:1.25rem;padding-top:.375em}.field-label.is-large{font-size:1.5rem;padding-top:.375em}}.field-body .field .field{margin-bottom:0}@media screen and (min-width:769px),print{.field-body{display:flex;flex-basis:0;flex-grow:5;flex-shrink:1}.field-body .field{margin-bottom:0}.field-body>.field{flex-shrink:1}.field-body>.field:not(.is-narrow){flex-grow:1}.field-body>.field:not(:last-child){margin-right:.75rem}}.control{box-sizing:border-box;clear:both;font-size:1rem;position:relative;text-align:inherit}.control.has-icons-left .input:focus~.icon,.control.has-icons-left .select:focus~.icon,.control.has-icons-right .input:focus~.icon,.control.has-icons-right .select:focus~.icon{color:#4a4a4a}.control.has-icons-left .input.is-small~.icon,.control.has-icons-left .select.is-small~.icon,.control.has-icons-right .input.is-small~.icon,.control.has-icons-right .select.is-small~.icon{font-size:.75rem}.control.has-icons-left .input.is-medium~.icon,.control.has-icons-left .select.is-medium~.icon,.control.has-icons-right .input.is-medium~.icon,.control.has-icons-right .select.is-medium~.icon{font-size:1.25rem}.control.has-icons-left .input.is-large~.icon,.control.has-icons-left .select.is-large~.icon,.control.has-icons-right .input.is-large~.icon,.control.has-icons-right .select.is-large~.icon{font-size:1.5rem}.control.has-icons-left .icon,.control.has-icons-right .icon{color:#dbdbdb;height:2.5em;pointer-events:none;position:absolute;top:0;width:2.5em;z-index:4}.control.has-icons-left .input,.control.has-icons-left .select select{padding-left:2.5em}.control.has-icons-left .icon.is-left{left:0}.control.has-icons-right .input,.control.has-icons-right .select select{padding-right:2.5em}.control.has-icons-right .icon.is-right{right:0}.control.is-loading::after{position:absolute!important;right:.625em;top:.625em;z-index:4}.control.is-loading.is-small:after{font-size:.75rem}.control.is-loading.is-medium:after{font-size:1.25rem}.control.is-loading.is-large:after{font-size:1.5rem}.breadcrumb{font-size:1rem;white-space:nowrap}.breadcrumb a{align-items:center;color:#3273dc;display:flex;justify-content:center;padding:0 .75em}.breadcrumb a:hover{color:#363636}.breadcrumb li{align-items:center;display:flex}.breadcrumb li:first-child a{padding-left:0}.breadcrumb li.is-active a{color:#363636;cursor:default;pointer-events:none}.breadcrumb li+li::before{color:#b5b5b5;content:"\0002f"}.breadcrumb ol,.breadcrumb ul{align-items:flex-start;display:flex;flex-wrap:wrap;justify-content:flex-start}.breadcrumb .icon:first-child{margin-right:.5em}.breadcrumb .icon:last-child{margin-left:.5em}.breadcrumb.is-centered ol,.breadcrumb.is-centered ul{justify-content:center}.breadcrumb.is-right ol,.breadcrumb.is-right ul{justify-content:flex-end}.breadcrumb.is-small{font-size:.75rem}.breadcrumb.is-medium{font-size:1.25rem}.breadcrumb.is-large{font-size:1.5rem}.breadcrumb.has-arrow-separator li+li::before{content:"\02192"}.breadcrumb.has-bullet-separator li+li::before{content:"\02022"}.breadcrumb.has-dot-separator li+li::before{content:"\000b7"}.breadcrumb.has-succeeds-separator li+li::before{content:"\0227B"}.card{background-color:#fff;border-radius:.25rem;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);color:#4a4a4a;max-width:100%;overflow:hidden;position:relative}.card-header{background-color:transparent;align-items:stretch;box-shadow:0 .125em .25em rgba(10,10,10,.1);display:flex}.card-header-title{align-items:center;color:#363636;display:flex;flex-grow:1;font-weight:700;padding:.75rem 1rem}.card-header-title.is-centered{justify-content:center}.card-header-icon{align-items:center;cursor:pointer;display:flex;justify-content:center;padding:.75rem 1rem}.card-image{display:block;position:relative}.card-content{background-color:transparent;padding:1.5rem}.card-footer{background-color:transparent;border-top:1px solid #ededed;align-items:stretch;display:flex}.card-footer-item{align-items:center;display:flex;flex-basis:0;flex-grow:1;flex-shrink:0;justify-content:center;padding:.75rem}.card-footer-item:not(:last-child){border-right:1px solid #ededed}.card .media:not(:last-child){margin-bottom:1.5rem}.dropdown{display:inline-flex;position:relative;vertical-align:top}.dropdown.is-active .dropdown-menu,.dropdown.is-hoverable:hover .dropdown-menu{display:block}.dropdown.is-right .dropdown-menu{left:auto;right:0}.dropdown.is-up .dropdown-menu{bottom:100%;padding-bottom:4px;padding-top:initial;top:auto}.dropdown-menu{display:none;left:0;min-width:12rem;padding-top:4px;position:absolute;top:100%;z-index:20}.dropdown-content{background-color:#fff;border-radius:4px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);padding-bottom:.5rem;padding-top:.5rem}.dropdown-item{color:#4a4a4a;display:block;font-size:.875rem;line-height:1.5;padding:.375rem 1rem;position:relative}a.dropdown-item,button.dropdown-item{padding-right:3rem;text-align:inherit;white-space:nowrap;width:100%}a.dropdown-item:hover,button.dropdown-item:hover{background-color:#f5f5f5;color:#0a0a0a}a.dropdown-item.is-active,button.dropdown-item.is-active{background-color:#3273dc;color:#fff}.dropdown-divider{background-color:#ededed;border:none;display:block;height:1px;margin:.5rem 0}.level{align-items:center;justify-content:space-between}.level code{border-radius:4px}.level img{display:inline-block;vertical-align:top}.level.is-mobile{display:flex}.level.is-mobile .level-left,.level.is-mobile .level-right{display:flex}.level.is-mobile .level-left+.level-right{margin-top:0}.level.is-mobile .level-item:not(:last-child){margin-bottom:0;margin-right:.75rem}.level.is-mobile .level-item:not(.is-narrow){flex-grow:1}@media screen and (min-width:769px),print{.level{display:flex}.level>.level-item:not(.is-narrow){flex-grow:1}}.level-item{align-items:center;display:flex;flex-basis:auto;flex-grow:0;flex-shrink:0;justify-content:center}.level-item .subtitle,.level-item .title{margin-bottom:0}@media screen and (max-width:768px){.level-item:not(:last-child){margin-bottom:.75rem}}.level-left,.level-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.level-left .level-item.is-flexible,.level-right .level-item.is-flexible{flex-grow:1}@media screen and (min-width:769px),print{.level-left .level-item:not(:last-child),.level-right .level-item:not(:last-child){margin-right:.75rem}}.level-left{align-items:center;justify-content:flex-start}@media screen and (max-width:768px){.level-left+.level-right{margin-top:1.5rem}}@media screen and (min-width:769px),print{.level-left{display:flex}}.level-right{align-items:center;justify-content:flex-end}@media screen and (min-width:769px),print{.level-right{display:flex}}.media{align-items:flex-start;display:flex;text-align:inherit}.media .content:not(:last-child){margin-bottom:.75rem}.media .media{border-top:1px solid rgba(219,219,219,.5);display:flex;padding-top:.75rem}.media .media .content:not(:last-child),.media .media .control:not(:last-child){margin-bottom:.5rem}.media .media .media{padding-top:.5rem}.media .media .media+.media{margin-top:.5rem}.media+.media{border-top:1px solid rgba(219,219,219,.5);margin-top:1rem;padding-top:1rem}.media.is-large+.media{margin-top:1.5rem;padding-top:1.5rem}.media-left,.media-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.media-left{margin-right:1rem}.media-right{margin-left:1rem}.media-content{flex-basis:auto;flex-grow:1;flex-shrink:1;text-align:inherit}@media screen and (max-width:768px){.media-content{overflow-x:auto}}.menu{font-size:1rem}.menu.is-small{font-size:.75rem}.menu.is-medium{font-size:1.25rem}.menu.is-large{font-size:1.5rem}.menu-list{line-height:1.25}.menu-list a{border-radius:2px;color:#4a4a4a;display:block;padding:.5em .75em}.menu-list a:hover{background-color:#f5f5f5;color:#363636}.menu-list a.is-active{background-color:#3273dc;color:#fff}.menu-list li ul{border-left:1px solid #dbdbdb;margin:.75em;padding-left:.75em}.menu-label{color:#7a7a7a;font-size:.75em;letter-spacing:.1em;text-transform:uppercase}.menu-label:not(:first-child){margin-top:1em}.menu-label:not(:last-child){margin-bottom:1em}.message{background-color:#f5f5f5;border-radius:4px;font-size:1rem}.message strong{color:currentColor}.message a:not(.button):not(.tag):not(.dropdown-item){color:currentColor;text-decoration:underline}.message.is-small{font-size:.75rem}.message.is-medium{font-size:1.25rem}.message.is-large{font-size:1.5rem}.message.is-white{background-color:#fff}.message.is-white .message-header{background-color:#fff;color:#0a0a0a}.message.is-white .message-body{border-color:#fff}.message.is-black{background-color:#fafafa}.message.is-black .message-header{background-color:#0a0a0a;color:#fff}.message.is-black .message-body{border-color:#0a0a0a}.message.is-light{background-color:#fafafa}.message.is-light .message-header{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.message.is-light .message-body{border-color:#f5f5f5}.message.is-dark{background-color:#fafafa}.message.is-dark .message-header{background-color:#363636;color:#fff}.message.is-dark .message-body{border-color:#363636}.message.is-primary{background-color:#ebfffc}.message.is-primary .message-header{background-color:#00d1b2;color:#fff}.message.is-primary .message-body{border-color:#00d1b2;color:#00947e}.message.is-link{background-color:#eef3fc}.message.is-link .message-header{background-color:#3273dc;color:#fff}.message.is-link .message-body{border-color:#3273dc;color:#2160c4}.message.is-info{background-color:#eef6fc}.message.is-info .message-header{background-color:#3298dc;color:#fff}.message.is-info .message-body{border-color:#3298dc;color:#1d72aa}.message.is-success{background-color:#effaf3}.message.is-success .message-header{background-color:#48c774;color:#fff}.message.is-success .message-body{border-color:#48c774;color:#257942}.message.is-warning{background-color:#fffbeb}.message.is-warning .message-header{background-color:#ffdd57;color:rgba(0,0,0,.7)}.message.is-warning .message-body{border-color:#ffdd57;color:#947600}.message.is-danger{background-color:#feecf0}.message.is-danger .message-header{background-color:#f14668;color:#fff}.message.is-danger .message-body{border-color:#f14668;color:#cc0f35}.message-header{align-items:center;background-color:#4a4a4a;border-radius:4px 4px 0 0;color:#fff;display:flex;font-weight:700;justify-content:space-between;line-height:1.25;padding:.75em 1em;position:relative}.message-header .delete{flex-grow:0;flex-shrink:0;margin-left:.75em}.message-header+.message-body{border-width:0;border-top-left-radius:0;border-top-right-radius:0}.message-body{border-color:#dbdbdb;border-radius:4px;border-style:solid;border-width:0 0 0 4px;color:#4a4a4a;padding:1.25em 1.5em}.message-body code,.message-body pre{background-color:#fff}.message-body pre code{background-color:transparent}.modal{align-items:center;display:none;flex-direction:column;justify-content:center;overflow:hidden;position:fixed;z-index:40}.modal.is-active{display:flex}.modal-background{background-color:rgba(10,10,10,.86)}.modal-card,.modal-content{margin:0 20px;max-height:calc(100vh - 160px);overflow:auto;position:relative;width:100%}@media screen and (min-width:769px){.modal-card,.modal-content{margin:0 auto;max-height:calc(100vh - 40px);width:640px}}.modal-close{background:0 0;height:40px;position:fixed;right:20px;top:20px;width:40px}.modal-card{display:flex;flex-direction:column;max-height:calc(100vh - 40px);overflow:hidden;-ms-overflow-y:visible}.modal-card-foot,.modal-card-head{align-items:center;background-color:#f5f5f5;display:flex;flex-shrink:0;justify-content:flex-start;padding:20px;position:relative}.modal-card-head{border-bottom:1px solid #dbdbdb;border-top-left-radius:6px;border-top-right-radius:6px}.modal-card-title{color:#363636;flex-grow:1;flex-shrink:0;font-size:1.5rem;line-height:1}.modal-card-foot{border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:1px solid #dbdbdb}.modal-card-foot .button:not(:last-child){margin-right:.5em}.modal-card-body{-webkit-overflow-scrolling:touch;background-color:#fff;flex-grow:1;flex-shrink:1;overflow:auto;padding:20px}.navbar{background-color:#fff;min-height:3.25rem;position:relative;z-index:30}.navbar.is-white{background-color:#fff;color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link,.navbar.is-white .navbar-brand>.navbar-item{color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link.is-active,.navbar.is-white .navbar-brand .navbar-link:focus,.navbar.is-white .navbar-brand .navbar-link:hover,.navbar.is-white .navbar-brand>a.navbar-item.is-active,.navbar.is-white .navbar-brand>a.navbar-item:focus,.navbar.is-white .navbar-brand>a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link::after{border-color:#0a0a0a}.navbar.is-white .navbar-burger{color:#0a0a0a}@media screen and (min-width:1024px){.navbar.is-white .navbar-end .navbar-link,.navbar.is-white .navbar-end>.navbar-item,.navbar.is-white .navbar-start .navbar-link,.navbar.is-white .navbar-start>.navbar-item{color:#0a0a0a}.navbar.is-white .navbar-end .navbar-link.is-active,.navbar.is-white .navbar-end .navbar-link:focus,.navbar.is-white .navbar-end .navbar-link:hover,.navbar.is-white .navbar-end>a.navbar-item.is-active,.navbar.is-white .navbar-end>a.navbar-item:focus,.navbar.is-white .navbar-end>a.navbar-item:hover,.navbar.is-white .navbar-start .navbar-link.is-active,.navbar.is-white .navbar-start .navbar-link:focus,.navbar.is-white .navbar-start .navbar-link:hover,.navbar.is-white .navbar-start>a.navbar-item.is-active,.navbar.is-white .navbar-start>a.navbar-item:focus,.navbar.is-white .navbar-start>a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-end .navbar-link::after,.navbar.is-white .navbar-start .navbar-link::after{border-color:#0a0a0a}.navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-white .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-white .navbar-item.has-dropdown:hover .navbar-link{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-dropdown a.navbar-item.is-active{background-color:#fff;color:#0a0a0a}}.navbar.is-black{background-color:#0a0a0a;color:#fff}.navbar.is-black .navbar-brand .navbar-link,.navbar.is-black .navbar-brand>.navbar-item{color:#fff}.navbar.is-black .navbar-brand .navbar-link.is-active,.navbar.is-black .navbar-brand .navbar-link:focus,.navbar.is-black .navbar-brand .navbar-link:hover,.navbar.is-black .navbar-brand>a.navbar-item.is-active,.navbar.is-black .navbar-brand>a.navbar-item:focus,.navbar.is-black .navbar-brand>a.navbar-item:hover{background-color:#000;color:#fff}.navbar.is-black .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-black .navbar-end .navbar-link,.navbar.is-black .navbar-end>.navbar-item,.navbar.is-black .navbar-start .navbar-link,.navbar.is-black .navbar-start>.navbar-item{color:#fff}.navbar.is-black .navbar-end .navbar-link.is-active,.navbar.is-black .navbar-end .navbar-link:focus,.navbar.is-black .navbar-end .navbar-link:hover,.navbar.is-black .navbar-end>a.navbar-item.is-active,.navbar.is-black .navbar-end>a.navbar-item:focus,.navbar.is-black .navbar-end>a.navbar-item:hover,.navbar.is-black .navbar-start .navbar-link.is-active,.navbar.is-black .navbar-start .navbar-link:focus,.navbar.is-black .navbar-start .navbar-link:hover,.navbar.is-black .navbar-start>a.navbar-item.is-active,.navbar.is-black .navbar-start>a.navbar-item:focus,.navbar.is-black .navbar-start>a.navbar-item:hover{background-color:#000;color:#fff}.navbar.is-black .navbar-end .navbar-link::after,.navbar.is-black .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-black .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-black .navbar-item.has-dropdown:hover .navbar-link{background-color:#000;color:#fff}.navbar.is-black .navbar-dropdown a.navbar-item.is-active{background-color:#0a0a0a;color:#fff}}.navbar.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link,.navbar.is-light .navbar-brand>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link.is-active,.navbar.is-light .navbar-brand .navbar-link:focus,.navbar.is-light .navbar-brand .navbar-link:hover,.navbar.is-light .navbar-brand>a.navbar-item.is-active,.navbar.is-light .navbar-brand>a.navbar-item:focus,.navbar.is-light .navbar-brand>a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width:1024px){.navbar.is-light .navbar-end .navbar-link,.navbar.is-light .navbar-end>.navbar-item,.navbar.is-light .navbar-start .navbar-link,.navbar.is-light .navbar-start>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-end .navbar-link.is-active,.navbar.is-light .navbar-end .navbar-link:focus,.navbar.is-light .navbar-end .navbar-link:hover,.navbar.is-light .navbar-end>a.navbar-item.is-active,.navbar.is-light .navbar-end>a.navbar-item:focus,.navbar.is-light .navbar-end>a.navbar-item:hover,.navbar.is-light .navbar-start .navbar-link.is-active,.navbar.is-light .navbar-start .navbar-link:focus,.navbar.is-light .navbar-start .navbar-link:hover,.navbar.is-light .navbar-start>a.navbar-item.is-active,.navbar.is-light .navbar-start>a.navbar-item:focus,.navbar.is-light .navbar-start>a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-end .navbar-link::after,.navbar.is-light .navbar-start .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-light .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-light .navbar-item.has-dropdown:hover .navbar-link{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:rgba(0,0,0,.7)}}.navbar.is-dark{background-color:#363636;color:#fff}.navbar.is-dark .navbar-brand .navbar-link,.navbar.is-dark .navbar-brand>.navbar-item{color:#fff}.navbar.is-dark .navbar-brand .navbar-link.is-active,.navbar.is-dark .navbar-brand .navbar-link:focus,.navbar.is-dark .navbar-brand .navbar-link:hover,.navbar.is-dark .navbar-brand>a.navbar-item.is-active,.navbar.is-dark .navbar-brand>a.navbar-item:focus,.navbar.is-dark .navbar-brand>a.navbar-item:hover{background-color:#292929;color:#fff}.navbar.is-dark .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-dark .navbar-end .navbar-link,.navbar.is-dark .navbar-end>.navbar-item,.navbar.is-dark .navbar-start .navbar-link,.navbar.is-dark .navbar-start>.navbar-item{color:#fff}.navbar.is-dark .navbar-end .navbar-link.is-active,.navbar.is-dark .navbar-end .navbar-link:focus,.navbar.is-dark .navbar-end .navbar-link:hover,.navbar.is-dark .navbar-end>a.navbar-item.is-active,.navbar.is-dark .navbar-end>a.navbar-item:focus,.navbar.is-dark .navbar-end>a.navbar-item:hover,.navbar.is-dark .navbar-start .navbar-link.is-active,.navbar.is-dark .navbar-start .navbar-link:focus,.navbar.is-dark .navbar-start .navbar-link:hover,.navbar.is-dark .navbar-start>a.navbar-item.is-active,.navbar.is-dark .navbar-start>a.navbar-item:focus,.navbar.is-dark .navbar-start>a.navbar-item:hover{background-color:#292929;color:#fff}.navbar.is-dark .navbar-end .navbar-link::after,.navbar.is-dark .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:hover .navbar-link{background-color:#292929;color:#fff}.navbar.is-dark .navbar-dropdown a.navbar-item.is-active{background-color:#363636;color:#fff}}.navbar.is-primary{background-color:#00d1b2;color:#fff}.navbar.is-primary .navbar-brand .navbar-link,.navbar.is-primary .navbar-brand>.navbar-item{color:#fff}.navbar.is-primary .navbar-brand .navbar-link.is-active,.navbar.is-primary .navbar-brand .navbar-link:focus,.navbar.is-primary .navbar-brand .navbar-link:hover,.navbar.is-primary .navbar-brand>a.navbar-item.is-active,.navbar.is-primary .navbar-brand>a.navbar-item:focus,.navbar.is-primary .navbar-brand>a.navbar-item:hover{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-primary .navbar-end .navbar-link,.navbar.is-primary .navbar-end>.navbar-item,.navbar.is-primary .navbar-start .navbar-link,.navbar.is-primary .navbar-start>.navbar-item{color:#fff}.navbar.is-primary .navbar-end .navbar-link.is-active,.navbar.is-primary .navbar-end .navbar-link:focus,.navbar.is-primary .navbar-end .navbar-link:hover,.navbar.is-primary .navbar-end>a.navbar-item.is-active,.navbar.is-primary .navbar-end>a.navbar-item:focus,.navbar.is-primary .navbar-end>a.navbar-item:hover,.navbar.is-primary .navbar-start .navbar-link.is-active,.navbar.is-primary .navbar-start .navbar-link:focus,.navbar.is-primary .navbar-start .navbar-link:hover,.navbar.is-primary .navbar-start>a.navbar-item.is-active,.navbar.is-primary .navbar-start>a.navbar-item:focus,.navbar.is-primary .navbar-start>a.navbar-item:hover{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-end .navbar-link::after,.navbar.is-primary .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:hover .navbar-link{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-dropdown a.navbar-item.is-active{background-color:#00d1b2;color:#fff}}.navbar.is-link{background-color:#3273dc;color:#fff}.navbar.is-link .navbar-brand .navbar-link,.navbar.is-link .navbar-brand>.navbar-item{color:#fff}.navbar.is-link .navbar-brand .navbar-link.is-active,.navbar.is-link .navbar-brand .navbar-link:focus,.navbar.is-link .navbar-brand .navbar-link:hover,.navbar.is-link .navbar-brand>a.navbar-item.is-active,.navbar.is-link .navbar-brand>a.navbar-item:focus,.navbar.is-link .navbar-brand>a.navbar-item:hover{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-link .navbar-end .navbar-link,.navbar.is-link .navbar-end>.navbar-item,.navbar.is-link .navbar-start .navbar-link,.navbar.is-link .navbar-start>.navbar-item{color:#fff}.navbar.is-link .navbar-end .navbar-link.is-active,.navbar.is-link .navbar-end .navbar-link:focus,.navbar.is-link .navbar-end .navbar-link:hover,.navbar.is-link .navbar-end>a.navbar-item.is-active,.navbar.is-link .navbar-end>a.navbar-item:focus,.navbar.is-link .navbar-end>a.navbar-item:hover,.navbar.is-link .navbar-start .navbar-link.is-active,.navbar.is-link .navbar-start .navbar-link:focus,.navbar.is-link .navbar-start .navbar-link:hover,.navbar.is-link .navbar-start>a.navbar-item.is-active,.navbar.is-link .navbar-start>a.navbar-item:focus,.navbar.is-link .navbar-start>a.navbar-item:hover{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-end .navbar-link::after,.navbar.is-link .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-link .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-link .navbar-item.has-dropdown:hover .navbar-link{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-dropdown a.navbar-item.is-active{background-color:#3273dc;color:#fff}}.navbar.is-info{background-color:#3298dc;color:#fff}.navbar.is-info .navbar-brand .navbar-link,.navbar.is-info .navbar-brand>.navbar-item{color:#fff}.navbar.is-info .navbar-brand .navbar-link.is-active,.navbar.is-info .navbar-brand .navbar-link:focus,.navbar.is-info .navbar-brand .navbar-link:hover,.navbar.is-info .navbar-brand>a.navbar-item.is-active,.navbar.is-info .navbar-brand>a.navbar-item:focus,.navbar.is-info .navbar-brand>a.navbar-item:hover{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-info .navbar-end .navbar-link,.navbar.is-info .navbar-end>.navbar-item,.navbar.is-info .navbar-start .navbar-link,.navbar.is-info .navbar-start>.navbar-item{color:#fff}.navbar.is-info .navbar-end .navbar-link.is-active,.navbar.is-info .navbar-end .navbar-link:focus,.navbar.is-info .navbar-end .navbar-link:hover,.navbar.is-info .navbar-end>a.navbar-item.is-active,.navbar.is-info .navbar-end>a.navbar-item:focus,.navbar.is-info .navbar-end>a.navbar-item:hover,.navbar.is-info .navbar-start .navbar-link.is-active,.navbar.is-info .navbar-start .navbar-link:focus,.navbar.is-info .navbar-start .navbar-link:hover,.navbar.is-info .navbar-start>a.navbar-item.is-active,.navbar.is-info .navbar-start>a.navbar-item:focus,.navbar.is-info .navbar-start>a.navbar-item:hover{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-end .navbar-link::after,.navbar.is-info .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-info .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-info .navbar-item.has-dropdown:hover .navbar-link{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-dropdown a.navbar-item.is-active{background-color:#3298dc;color:#fff}}.navbar.is-success{background-color:#48c774;color:#fff}.navbar.is-success .navbar-brand .navbar-link,.navbar.is-success .navbar-brand>.navbar-item{color:#fff}.navbar.is-success .navbar-brand .navbar-link.is-active,.navbar.is-success .navbar-brand .navbar-link:focus,.navbar.is-success .navbar-brand .navbar-link:hover,.navbar.is-success .navbar-brand>a.navbar-item.is-active,.navbar.is-success .navbar-brand>a.navbar-item:focus,.navbar.is-success .navbar-brand>a.navbar-item:hover{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-success .navbar-end .navbar-link,.navbar.is-success .navbar-end>.navbar-item,.navbar.is-success .navbar-start .navbar-link,.navbar.is-success .navbar-start>.navbar-item{color:#fff}.navbar.is-success .navbar-end .navbar-link.is-active,.navbar.is-success .navbar-end .navbar-link:focus,.navbar.is-success .navbar-end .navbar-link:hover,.navbar.is-success .navbar-end>a.navbar-item.is-active,.navbar.is-success .navbar-end>a.navbar-item:focus,.navbar.is-success .navbar-end>a.navbar-item:hover,.navbar.is-success .navbar-start .navbar-link.is-active,.navbar.is-success .navbar-start .navbar-link:focus,.navbar.is-success .navbar-start .navbar-link:hover,.navbar.is-success .navbar-start>a.navbar-item.is-active,.navbar.is-success .navbar-start>a.navbar-item:focus,.navbar.is-success .navbar-start>a.navbar-item:hover{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-end .navbar-link::after,.navbar.is-success .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-success .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-success .navbar-item.has-dropdown:hover .navbar-link{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-dropdown a.navbar-item.is-active{background-color:#48c774;color:#fff}}.navbar.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link,.navbar.is-warning .navbar-brand>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link.is-active,.navbar.is-warning .navbar-brand .navbar-link:focus,.navbar.is-warning .navbar-brand .navbar-link:hover,.navbar.is-warning .navbar-brand>a.navbar-item.is-active,.navbar.is-warning .navbar-brand>a.navbar-item:focus,.navbar.is-warning .navbar-brand>a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width:1024px){.navbar.is-warning .navbar-end .navbar-link,.navbar.is-warning .navbar-end>.navbar-item,.navbar.is-warning .navbar-start .navbar-link,.navbar.is-warning .navbar-start>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-end .navbar-link.is-active,.navbar.is-warning .navbar-end .navbar-link:focus,.navbar.is-warning .navbar-end .navbar-link:hover,.navbar.is-warning .navbar-end>a.navbar-item.is-active,.navbar.is-warning .navbar-end>a.navbar-item:focus,.navbar.is-warning .navbar-end>a.navbar-item:hover,.navbar.is-warning .navbar-start .navbar-link.is-active,.navbar.is-warning .navbar-start .navbar-link:focus,.navbar.is-warning .navbar-start .navbar-link:hover,.navbar.is-warning .navbar-start>a.navbar-item.is-active,.navbar.is-warning .navbar-start>a.navbar-item:focus,.navbar.is-warning .navbar-start>a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-end .navbar-link::after,.navbar.is-warning .navbar-start .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:hover .navbar-link{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-dropdown a.navbar-item.is-active{background-color:#ffdd57;color:rgba(0,0,0,.7)}}.navbar.is-danger{background-color:#f14668;color:#fff}.navbar.is-danger .navbar-brand .navbar-link,.navbar.is-danger .navbar-brand>.navbar-item{color:#fff}.navbar.is-danger .navbar-brand .navbar-link.is-active,.navbar.is-danger .navbar-brand .navbar-link:focus,.navbar.is-danger .navbar-brand .navbar-link:hover,.navbar.is-danger .navbar-brand>a.navbar-item.is-active,.navbar.is-danger .navbar-brand>a.navbar-item:focus,.navbar.is-danger .navbar-brand>a.navbar-item:hover{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-danger .navbar-end .navbar-link,.navbar.is-danger .navbar-end>.navbar-item,.navbar.is-danger .navbar-start .navbar-link,.navbar.is-danger .navbar-start>.navbar-item{color:#fff}.navbar.is-danger .navbar-end .navbar-link.is-active,.navbar.is-danger .navbar-end .navbar-link:focus,.navbar.is-danger .navbar-end .navbar-link:hover,.navbar.is-danger .navbar-end>a.navbar-item.is-active,.navbar.is-danger .navbar-end>a.navbar-item:focus,.navbar.is-danger .navbar-end>a.navbar-item:hover,.navbar.is-danger .navbar-start .navbar-link.is-active,.navbar.is-danger .navbar-start .navbar-link:focus,.navbar.is-danger .navbar-start .navbar-link:hover,.navbar.is-danger .navbar-start>a.navbar-item.is-active,.navbar.is-danger .navbar-start>a.navbar-item:focus,.navbar.is-danger .navbar-start>a.navbar-item:hover{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-end .navbar-link::after,.navbar.is-danger .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:hover .navbar-link{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-dropdown a.navbar-item.is-active{background-color:#f14668;color:#fff}}.navbar>.container{align-items:stretch;display:flex;min-height:3.25rem;width:100%}.navbar.has-shadow{box-shadow:0 2px 0 0 #f5f5f5}.navbar.is-fixed-bottom,.navbar.is-fixed-top{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom{bottom:0}.navbar.is-fixed-bottom.has-shadow{box-shadow:0 -2px 0 0 #f5f5f5}.navbar.is-fixed-top{top:0}body.has-navbar-fixed-top,html.has-navbar-fixed-top{padding-top:3.25rem}body.has-navbar-fixed-bottom,html.has-navbar-fixed-bottom{padding-bottom:3.25rem}.navbar-brand,.navbar-tabs{align-items:stretch;display:flex;flex-shrink:0;min-height:3.25rem}.navbar-brand a.navbar-item:focus,.navbar-brand a.navbar-item:hover{background-color:transparent}.navbar-tabs{-webkit-overflow-scrolling:touch;max-width:100vw;overflow-x:auto;overflow-y:hidden}.navbar-burger{color:#4a4a4a;cursor:pointer;display:block;height:3.25rem;position:relative;width:3.25rem;margin-left:auto}.navbar-burger span{background-color:currentColor;display:block;height:1px;left:calc(50% - 8px);position:absolute;transform-origin:center;transition-duration:86ms;transition-property:background-color,opacity,transform;transition-timing-function:ease-out;width:16px}.navbar-burger span:nth-child(1){top:calc(50% - 6px)}.navbar-burger span:nth-child(2){top:calc(50% - 1px)}.navbar-burger span:nth-child(3){top:calc(50% + 4px)}.navbar-burger:hover{background-color:rgba(0,0,0,.05)}.navbar-burger.is-active span:nth-child(1){transform:translateY(5px) rotate(45deg)}.navbar-burger.is-active span:nth-child(2){opacity:0}.navbar-burger.is-active span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.navbar-menu{display:none}.navbar-item,.navbar-link{color:#4a4a4a;display:block;line-height:1.5;padding:.5rem .75rem;position:relative}.navbar-item .icon:only-child,.navbar-link .icon:only-child{margin-left:-.25rem;margin-right:-.25rem}.navbar-link,a.navbar-item{cursor:pointer}.navbar-link.is-active,.navbar-link:focus,.navbar-link:focus-within,.navbar-link:hover,a.navbar-item.is-active,a.navbar-item:focus,a.navbar-item:focus-within,a.navbar-item:hover{background-color:#fafafa;color:#3273dc}.navbar-item{flex-grow:0;flex-shrink:0}.navbar-item img{max-height:1.75rem}.navbar-item.has-dropdown{padding:0}.navbar-item.is-expanded{flex-grow:1;flex-shrink:1}.navbar-item.is-tab{border-bottom:1px solid transparent;min-height:3.25rem;padding-bottom:calc(.5rem - 1px)}.navbar-item.is-tab:focus,.navbar-item.is-tab:hover{background-color:transparent;border-bottom-color:#3273dc}.navbar-item.is-tab.is-active{background-color:transparent;border-bottom-color:#3273dc;border-bottom-style:solid;border-bottom-width:3px;color:#3273dc;padding-bottom:calc(.5rem - 3px)}.navbar-content{flex-grow:1;flex-shrink:1}.navbar-link:not(.is-arrowless){padding-right:2.5em}.navbar-link:not(.is-arrowless)::after{border-color:#3273dc;margin-top:-.375em;right:1.125em}.navbar-dropdown{font-size:.875rem;padding-bottom:.5rem;padding-top:.5rem}.navbar-dropdown .navbar-item{padding-left:1.5rem;padding-right:1.5rem}.navbar-divider{background-color:#f5f5f5;border:none;display:none;height:2px;margin:.5rem 0}@media screen and (max-width:1023px){.navbar>.container{display:block}.navbar-brand .navbar-item,.navbar-tabs .navbar-item{align-items:center;display:flex}.navbar-link::after{display:none}.navbar-menu{background-color:#fff;box-shadow:0 8px 16px rgba(10,10,10,.1);padding:.5rem 0}.navbar-menu.is-active{display:block}.navbar.is-fixed-bottom-touch,.navbar.is-fixed-top-touch{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-touch{bottom:0}.navbar.is-fixed-bottom-touch.has-shadow{box-shadow:0 -2px 3px rgba(10,10,10,.1)}.navbar.is-fixed-top-touch{top:0}.navbar.is-fixed-top .navbar-menu,.navbar.is-fixed-top-touch .navbar-menu{-webkit-overflow-scrolling:touch;max-height:calc(100vh - 3.25rem);overflow:auto}body.has-navbar-fixed-top-touch,html.has-navbar-fixed-top-touch{padding-top:3.25rem}body.has-navbar-fixed-bottom-touch,html.has-navbar-fixed-bottom-touch{padding-bottom:3.25rem}}@media screen and (min-width:1024px){.navbar,.navbar-end,.navbar-menu,.navbar-start{align-items:stretch;display:flex}.navbar{min-height:3.25rem}.navbar.is-spaced{padding:1rem 2rem}.navbar.is-spaced .navbar-end,.navbar.is-spaced .navbar-start{align-items:center}.navbar.is-spaced .navbar-link,.navbar.is-spaced a.navbar-item{border-radius:4px}.navbar.is-transparent .navbar-link.is-active,.navbar.is-transparent .navbar-link:focus,.navbar.is-transparent .navbar-link:hover,.navbar.is-transparent a.navbar-item.is-active,.navbar.is-transparent a.navbar-item:focus,.navbar.is-transparent a.navbar-item:hover{background-color:transparent!important}.navbar.is-transparent .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus-within .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:hover .navbar-link{background-color:transparent!important}.navbar.is-transparent .navbar-dropdown a.navbar-item:focus,.navbar.is-transparent .navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar.is-transparent .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#3273dc}.navbar-burger{display:none}.navbar-item,.navbar-link{align-items:center;display:flex}.navbar-item.has-dropdown{align-items:stretch}.navbar-item.has-dropdown-up .navbar-link::after{transform:rotate(135deg) translate(.25em,-.25em)}.navbar-item.has-dropdown-up .navbar-dropdown{border-bottom:2px solid #dbdbdb;border-radius:6px 6px 0 0;border-top:none;bottom:100%;box-shadow:0 -8px 8px rgba(10,10,10,.1);top:auto}.navbar-item.is-active .navbar-dropdown,.navbar-item.is-hoverable:focus .navbar-dropdown,.navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar-item.is-hoverable:hover .navbar-dropdown{display:block}.navbar-item.is-active .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:focus .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:focus-within .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:hover .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-active .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:focus .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:hover .navbar-dropdown{opacity:1;pointer-events:auto;transform:translateY(0)}.navbar-menu{flex-grow:1;flex-shrink:0}.navbar-start{justify-content:flex-start;margin-right:auto}.navbar-end{justify-content:flex-end;margin-left:auto}.navbar-dropdown{background-color:#fff;border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:2px solid #dbdbdb;box-shadow:0 8px 8px rgba(10,10,10,.1);display:none;font-size:.875rem;left:0;min-width:100%;position:absolute;top:100%;z-index:20}.navbar-dropdown .navbar-item{padding:.375rem 1rem;white-space:nowrap}.navbar-dropdown a.navbar-item{padding-right:3rem}.navbar-dropdown a.navbar-item:focus,.navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#3273dc}.navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-dropdown{border-radius:6px;border-top:none;box-shadow:0 8px 8px rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.1);display:block;opacity:0;pointer-events:none;top:calc(100% + (-4px));transform:translateY(-5px);transition-duration:86ms;transition-property:opacity,transform}.navbar-dropdown.is-right{left:auto;right:0}.navbar-divider{display:block}.container>.navbar .navbar-brand,.navbar>.container .navbar-brand{margin-left:-.75rem}.container>.navbar .navbar-menu,.navbar>.container .navbar-menu{margin-right:-.75rem}.navbar.is-fixed-bottom-desktop,.navbar.is-fixed-top-desktop{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-desktop{bottom:0}.navbar.is-fixed-bottom-desktop.has-shadow{box-shadow:0 -2px 3px rgba(10,10,10,.1)}.navbar.is-fixed-top-desktop{top:0}body.has-navbar-fixed-top-desktop,html.has-navbar-fixed-top-desktop{padding-top:3.25rem}body.has-navbar-fixed-bottom-desktop,html.has-navbar-fixed-bottom-desktop{padding-bottom:3.25rem}body.has-spaced-navbar-fixed-top,html.has-spaced-navbar-fixed-top{padding-top:5.25rem}body.has-spaced-navbar-fixed-bottom,html.has-spaced-navbar-fixed-bottom{padding-bottom:5.25rem}.navbar-link.is-active,a.navbar-item.is-active{color:#0a0a0a}.navbar-link.is-active:not(:focus):not(:hover),a.navbar-item.is-active:not(:focus):not(:hover){background-color:transparent}.navbar-item.has-dropdown.is-active .navbar-link,.navbar-item.has-dropdown:focus .navbar-link,.navbar-item.has-dropdown:hover .navbar-link{background-color:#fafafa}}.hero.is-fullheight-with-navbar{min-height:calc(100vh - 3.25rem)}.pagination{font-size:1rem;margin:-.25rem}.pagination.is-small{font-size:.75rem}.pagination.is-medium{font-size:1.25rem}.pagination.is-large{font-size:1.5rem}.pagination.is-rounded .pagination-next,.pagination.is-rounded .pagination-previous{padding-left:1em;padding-right:1em;border-radius:290486px}.pagination.is-rounded .pagination-link{border-radius:290486px}.pagination,.pagination-list{align-items:center;display:flex;justify-content:center;text-align:center}.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous{font-size:1em;justify-content:center;margin:.25rem;padding-left:.5em;padding-right:.5em;text-align:center}.pagination-link,.pagination-next,.pagination-previous{border-color:#dbdbdb;color:#363636;min-width:2.5em}.pagination-link:hover,.pagination-next:hover,.pagination-previous:hover{border-color:#b5b5b5;color:#363636}.pagination-link:focus,.pagination-next:focus,.pagination-previous:focus{border-color:#3273dc}.pagination-link:active,.pagination-next:active,.pagination-previous:active{box-shadow:inset 0 1px 2px rgba(10,10,10,.2)}.pagination-link[disabled],.pagination-next[disabled],.pagination-previous[disabled]{background-color:#dbdbdb;border-color:#dbdbdb;box-shadow:none;color:#7a7a7a;opacity:.5}.pagination-next,.pagination-previous{padding-left:.75em;padding-right:.75em;white-space:nowrap}.pagination-link.is-current{background-color:#3273dc;border-color:#3273dc;color:#fff}.pagination-ellipsis{color:#b5b5b5;pointer-events:none}.pagination-list{flex-wrap:wrap}@media screen and (max-width:768px){.pagination{flex-wrap:wrap}.pagination-next,.pagination-previous{flex-grow:1;flex-shrink:1}.pagination-list li{flex-grow:1;flex-shrink:1}}@media screen and (min-width:769px),print{.pagination-list{flex-grow:1;flex-shrink:1;justify-content:flex-start;order:1}.pagination-previous{order:2}.pagination-next{order:3}.pagination{justify-content:space-between}.pagination.is-centered .pagination-previous{order:1}.pagination.is-centered .pagination-list{justify-content:center;order:2}.pagination.is-centered .pagination-next{order:3}.pagination.is-right .pagination-previous{order:1}.pagination.is-right .pagination-next{order:2}.pagination.is-right .pagination-list{justify-content:flex-end;order:3}}.panel{border-radius:6px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);font-size:1rem}.panel:not(:last-child){margin-bottom:1.5rem}.panel.is-white .panel-heading{background-color:#fff;color:#0a0a0a}.panel.is-white .panel-tabs a.is-active{border-bottom-color:#fff}.panel.is-white .panel-block.is-active .panel-icon{color:#fff}.panel.is-black .panel-heading{background-color:#0a0a0a;color:#fff}.panel.is-black .panel-tabs a.is-active{border-bottom-color:#0a0a0a}.panel.is-black .panel-block.is-active .panel-icon{color:#0a0a0a}.panel.is-light .panel-heading{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.panel.is-light .panel-tabs a.is-active{border-bottom-color:#f5f5f5}.panel.is-light .panel-block.is-active .panel-icon{color:#f5f5f5}.panel.is-dark .panel-heading{background-color:#363636;color:#fff}.panel.is-dark .panel-tabs a.is-active{border-bottom-color:#363636}.panel.is-dark .panel-block.is-active .panel-icon{color:#363636}.panel.is-primary .panel-heading{background-color:#00d1b2;color:#fff}.panel.is-primary .panel-tabs a.is-active{border-bottom-color:#00d1b2}.panel.is-primary .panel-block.is-active .panel-icon{color:#00d1b2}.panel.is-link .panel-heading{background-color:#3273dc;color:#fff}.panel.is-link .panel-tabs a.is-active{border-bottom-color:#3273dc}.panel.is-link .panel-block.is-active .panel-icon{color:#3273dc}.panel.is-info .panel-heading{background-color:#3298dc;color:#fff}.panel.is-info .panel-tabs a.is-active{border-bottom-color:#3298dc}.panel.is-info .panel-block.is-active .panel-icon{color:#3298dc}.panel.is-success .panel-heading{background-color:#48c774;color:#fff}.panel.is-success .panel-tabs a.is-active{border-bottom-color:#48c774}.panel.is-success .panel-block.is-active .panel-icon{color:#48c774}.panel.is-warning .panel-heading{background-color:#ffdd57;color:rgba(0,0,0,.7)}.panel.is-warning .panel-tabs a.is-active{border-bottom-color:#ffdd57}.panel.is-warning .panel-block.is-active .panel-icon{color:#ffdd57}.panel.is-danger .panel-heading{background-color:#f14668;color:#fff}.panel.is-danger .panel-tabs a.is-active{border-bottom-color:#f14668}.panel.is-danger .panel-block.is-active .panel-icon{color:#f14668}.panel-block:not(:last-child),.panel-tabs:not(:last-child){border-bottom:1px solid #ededed}.panel-heading{background-color:#ededed;border-radius:6px 6px 0 0;color:#363636;font-size:1.25em;font-weight:700;line-height:1.25;padding:.75em 1em}.panel-tabs{align-items:flex-end;display:flex;font-size:.875em;justify-content:center}.panel-tabs a{border-bottom:1px solid #dbdbdb;margin-bottom:-1px;padding:.5em}.panel-tabs a.is-active{border-bottom-color:#4a4a4a;color:#363636}.panel-list a{color:#4a4a4a}.panel-list a:hover{color:#3273dc}.panel-block{align-items:center;color:#363636;display:flex;justify-content:flex-start;padding:.5em .75em}.panel-block input[type=checkbox]{margin-right:.75em}.panel-block>.control{flex-grow:1;flex-shrink:1;width:100%}.panel-block.is-wrapped{flex-wrap:wrap}.panel-block.is-active{border-left-color:#3273dc;color:#363636}.panel-block.is-active .panel-icon{color:#3273dc}.panel-block:last-child{border-bottom-left-radius:6px;border-bottom-right-radius:6px}a.panel-block,label.panel-block{cursor:pointer}a.panel-block:hover,label.panel-block:hover{background-color:#f5f5f5}.panel-icon{display:inline-block;font-size:14px;height:1em;line-height:1em;text-align:center;vertical-align:top;width:1em;color:#7a7a7a;margin-right:.75em}.panel-icon .fa{font-size:inherit;line-height:inherit}.tabs{-webkit-overflow-scrolling:touch;align-items:stretch;display:flex;font-size:1rem;justify-content:space-between;overflow:hidden;overflow-x:auto;white-space:nowrap}.tabs a{align-items:center;border-bottom-color:#dbdbdb;border-bottom-style:solid;border-bottom-width:1px;color:#4a4a4a;display:flex;justify-content:center;margin-bottom:-1px;padding:.5em 1em;vertical-align:top}.tabs a:hover{border-bottom-color:#363636;color:#363636}.tabs li{display:block}.tabs li.is-active a{border-bottom-color:#3273dc;color:#3273dc}.tabs ul{align-items:center;border-bottom-color:#dbdbdb;border-bottom-style:solid;border-bottom-width:1px;display:flex;flex-grow:1;flex-shrink:0;justify-content:flex-start}.tabs ul.is-left{padding-right:.75em}.tabs ul.is-center{flex:none;justify-content:center;padding-left:.75em;padding-right:.75em}.tabs ul.is-right{justify-content:flex-end;padding-left:.75em}.tabs .icon:first-child{margin-right:.5em}.tabs .icon:last-child{margin-left:.5em}.tabs.is-centered ul{justify-content:center}.tabs.is-right ul{justify-content:flex-end}.tabs.is-boxed a{border:1px solid transparent;border-radius:4px 4px 0 0}.tabs.is-boxed a:hover{background-color:#f5f5f5;border-bottom-color:#dbdbdb}.tabs.is-boxed li.is-active a{background-color:#fff;border-color:#dbdbdb;border-bottom-color:transparent!important}.tabs.is-fullwidth li{flex-grow:1;flex-shrink:0}.tabs.is-toggle a{border-color:#dbdbdb;border-style:solid;border-width:1px;margin-bottom:0;position:relative}.tabs.is-toggle a:hover{background-color:#f5f5f5;border-color:#b5b5b5;z-index:2}.tabs.is-toggle li+li{margin-left:-1px}.tabs.is-toggle li:first-child a{border-top-left-radius:4px;border-bottom-left-radius:4px}.tabs.is-toggle li:last-child a{border-top-right-radius:4px;border-bottom-right-radius:4px}.tabs.is-toggle li.is-active a{background-color:#3273dc;border-color:#3273dc;color:#fff;z-index:1}.tabs.is-toggle ul{border-bottom:none}.tabs.is-toggle.is-toggle-rounded li:first-child a{border-bottom-left-radius:290486px;border-top-left-radius:290486px;padding-left:1.25em}.tabs.is-toggle.is-toggle-rounded li:last-child a{border-bottom-right-radius:290486px;border-top-right-radius:290486px;padding-right:1.25em}.tabs.is-small{font-size:.75rem}.tabs.is-medium{font-size:1.25rem}.tabs.is-large{font-size:1.5rem}.column{display:block;flex-basis:0;flex-grow:1;flex-shrink:1;padding:.75rem}.columns.is-mobile>.column.is-narrow{flex:none}.columns.is-mobile>.column.is-full{flex:none;width:100%}.columns.is-mobile>.column.is-three-quarters{flex:none;width:75%}.columns.is-mobile>.column.is-two-thirds{flex:none;width:66.6666%}.columns.is-mobile>.column.is-half{flex:none;width:50%}.columns.is-mobile>.column.is-one-third{flex:none;width:33.3333%}.columns.is-mobile>.column.is-one-quarter{flex:none;width:25%}.columns.is-mobile>.column.is-one-fifth{flex:none;width:20%}.columns.is-mobile>.column.is-two-fifths{flex:none;width:40%}.columns.is-mobile>.column.is-three-fifths{flex:none;width:60%}.columns.is-mobile>.column.is-four-fifths{flex:none;width:80%}.columns.is-mobile>.column.is-offset-three-quarters{margin-left:75%}.columns.is-mobile>.column.is-offset-two-thirds{margin-left:66.6666%}.columns.is-mobile>.column.is-offset-half{margin-left:50%}.columns.is-mobile>.column.is-offset-one-third{margin-left:33.3333%}.columns.is-mobile>.column.is-offset-one-quarter{margin-left:25%}.columns.is-mobile>.column.is-offset-one-fifth{margin-left:20%}.columns.is-mobile>.column.is-offset-two-fifths{margin-left:40%}.columns.is-mobile>.column.is-offset-three-fifths{margin-left:60%}.columns.is-mobile>.column.is-offset-four-fifths{margin-left:80%}.columns.is-mobile>.column.is-0{flex:none;width:0%}.columns.is-mobile>.column.is-offset-0{margin-left:0}.columns.is-mobile>.column.is-1{flex:none;width:8.33333%}.columns.is-mobile>.column.is-offset-1{margin-left:8.33333%}.columns.is-mobile>.column.is-2{flex:none;width:16.66667%}.columns.is-mobile>.column.is-offset-2{margin-left:16.66667%}.columns.is-mobile>.column.is-3{flex:none;width:25%}.columns.is-mobile>.column.is-offset-3{margin-left:25%}.columns.is-mobile>.column.is-4{flex:none;width:33.33333%}.columns.is-mobile>.column.is-offset-4{margin-left:33.33333%}.columns.is-mobile>.column.is-5{flex:none;width:41.66667%}.columns.is-mobile>.column.is-offset-5{margin-left:41.66667%}.columns.is-mobile>.column.is-6{flex:none;width:50%}.columns.is-mobile>.column.is-offset-6{margin-left:50%}.columns.is-mobile>.column.is-7{flex:none;width:58.33333%}.columns.is-mobile>.column.is-offset-7{margin-left:58.33333%}.columns.is-mobile>.column.is-8{flex:none;width:66.66667%}.columns.is-mobile>.column.is-offset-8{margin-left:66.66667%}.columns.is-mobile>.column.is-9{flex:none;width:75%}.columns.is-mobile>.column.is-offset-9{margin-left:75%}.columns.is-mobile>.column.is-10{flex:none;width:83.33333%}.columns.is-mobile>.column.is-offset-10{margin-left:83.33333%}.columns.is-mobile>.column.is-11{flex:none;width:91.66667%}.columns.is-mobile>.column.is-offset-11{margin-left:91.66667%}.columns.is-mobile>.column.is-12{flex:none;width:100%}.columns.is-mobile>.column.is-offset-12{margin-left:100%}@media screen and (max-width:768px){.column.is-narrow-mobile{flex:none}.column.is-full-mobile{flex:none;width:100%}.column.is-three-quarters-mobile{flex:none;width:75%}.column.is-two-thirds-mobile{flex:none;width:66.6666%}.column.is-half-mobile{flex:none;width:50%}.column.is-one-third-mobile{flex:none;width:33.3333%}.column.is-one-quarter-mobile{flex:none;width:25%}.column.is-one-fifth-mobile{flex:none;width:20%}.column.is-two-fifths-mobile{flex:none;width:40%}.column.is-three-fifths-mobile{flex:none;width:60%}.column.is-four-fifths-mobile{flex:none;width:80%}.column.is-offset-three-quarters-mobile{margin-left:75%}.column.is-offset-two-thirds-mobile{margin-left:66.6666%}.column.is-offset-half-mobile{margin-left:50%}.column.is-offset-one-third-mobile{margin-left:33.3333%}.column.is-offset-one-quarter-mobile{margin-left:25%}.column.is-offset-one-fifth-mobile{margin-left:20%}.column.is-offset-two-fifths-mobile{margin-left:40%}.column.is-offset-three-fifths-mobile{margin-left:60%}.column.is-offset-four-fifths-mobile{margin-left:80%}.column.is-0-mobile{flex:none;width:0%}.column.is-offset-0-mobile{margin-left:0}.column.is-1-mobile{flex:none;width:8.33333%}.column.is-offset-1-mobile{margin-left:8.33333%}.column.is-2-mobile{flex:none;width:16.66667%}.column.is-offset-2-mobile{margin-left:16.66667%}.column.is-3-mobile{flex:none;width:25%}.column.is-offset-3-mobile{margin-left:25%}.column.is-4-mobile{flex:none;width:33.33333%}.column.is-offset-4-mobile{margin-left:33.33333%}.column.is-5-mobile{flex:none;width:41.66667%}.column.is-offset-5-mobile{margin-left:41.66667%}.column.is-6-mobile{flex:none;width:50%}.column.is-offset-6-mobile{margin-left:50%}.column.is-7-mobile{flex:none;width:58.33333%}.column.is-offset-7-mobile{margin-left:58.33333%}.column.is-8-mobile{flex:none;width:66.66667%}.column.is-offset-8-mobile{margin-left:66.66667%}.column.is-9-mobile{flex:none;width:75%}.column.is-offset-9-mobile{margin-left:75%}.column.is-10-mobile{flex:none;width:83.33333%}.column.is-offset-10-mobile{margin-left:83.33333%}.column.is-11-mobile{flex:none;width:91.66667%}.column.is-offset-11-mobile{margin-left:91.66667%}.column.is-12-mobile{flex:none;width:100%}.column.is-offset-12-mobile{margin-left:100%}}@media screen and (min-width:769px),print{.column.is-narrow,.column.is-narrow-tablet{flex:none}.column.is-full,.column.is-full-tablet{flex:none;width:100%}.column.is-three-quarters,.column.is-three-quarters-tablet{flex:none;width:75%}.column.is-two-thirds,.column.is-two-thirds-tablet{flex:none;width:66.6666%}.column.is-half,.column.is-half-tablet{flex:none;width:50%}.column.is-one-third,.column.is-one-third-tablet{flex:none;width:33.3333%}.column.is-one-quarter,.column.is-one-quarter-tablet{flex:none;width:25%}.column.is-one-fifth,.column.is-one-fifth-tablet{flex:none;width:20%}.column.is-two-fifths,.column.is-two-fifths-tablet{flex:none;width:40%}.column.is-three-fifths,.column.is-three-fifths-tablet{flex:none;width:60%}.column.is-four-fifths,.column.is-four-fifths-tablet{flex:none;width:80%}.column.is-offset-three-quarters,.column.is-offset-three-quarters-tablet{margin-left:75%}.column.is-offset-two-thirds,.column.is-offset-two-thirds-tablet{margin-left:66.6666%}.column.is-offset-half,.column.is-offset-half-tablet{margin-left:50%}.column.is-offset-one-third,.column.is-offset-one-third-tablet{margin-left:33.3333%}.column.is-offset-one-quarter,.column.is-offset-one-quarter-tablet{margin-left:25%}.column.is-offset-one-fifth,.column.is-offset-one-fifth-tablet{margin-left:20%}.column.is-offset-two-fifths,.column.is-offset-two-fifths-tablet{margin-left:40%}.column.is-offset-three-fifths,.column.is-offset-three-fifths-tablet{margin-left:60%}.column.is-offset-four-fifths,.column.is-offset-four-fifths-tablet{margin-left:80%}.column.is-0,.column.is-0-tablet{flex:none;width:0%}.column.is-offset-0,.column.is-offset-0-tablet{margin-left:0}.column.is-1,.column.is-1-tablet{flex:none;width:8.33333%}.column.is-offset-1,.column.is-offset-1-tablet{margin-left:8.33333%}.column.is-2,.column.is-2-tablet{flex:none;width:16.66667%}.column.is-offset-2,.column.is-offset-2-tablet{margin-left:16.66667%}.column.is-3,.column.is-3-tablet{flex:none;width:25%}.column.is-offset-3,.column.is-offset-3-tablet{margin-left:25%}.column.is-4,.column.is-4-tablet{flex:none;width:33.33333%}.column.is-offset-4,.column.is-offset-4-tablet{margin-left:33.33333%}.column.is-5,.column.is-5-tablet{flex:none;width:41.66667%}.column.is-offset-5,.column.is-offset-5-tablet{margin-left:41.66667%}.column.is-6,.column.is-6-tablet{flex:none;width:50%}.column.is-offset-6,.column.is-offset-6-tablet{margin-left:50%}.column.is-7,.column.is-7-tablet{flex:none;width:58.33333%}.column.is-offset-7,.column.is-offset-7-tablet{margin-left:58.33333%}.column.is-8,.column.is-8-tablet{flex:none;width:66.66667%}.column.is-offset-8,.column.is-offset-8-tablet{margin-left:66.66667%}.column.is-9,.column.is-9-tablet{flex:none;width:75%}.column.is-offset-9,.column.is-offset-9-tablet{margin-left:75%}.column.is-10,.column.is-10-tablet{flex:none;width:83.33333%}.column.is-offset-10,.column.is-offset-10-tablet{margin-left:83.33333%}.column.is-11,.column.is-11-tablet{flex:none;width:91.66667%}.column.is-offset-11,.column.is-offset-11-tablet{margin-left:91.66667%}.column.is-12,.column.is-12-tablet{flex:none;width:100%}.column.is-offset-12,.column.is-offset-12-tablet{margin-left:100%}}@media screen and (max-width:1023px){.column.is-narrow-touch{flex:none}.column.is-full-touch{flex:none;width:100%}.column.is-three-quarters-touch{flex:none;width:75%}.column.is-two-thirds-touch{flex:none;width:66.6666%}.column.is-half-touch{flex:none;width:50%}.column.is-one-third-touch{flex:none;width:33.3333%}.column.is-one-quarter-touch{flex:none;width:25%}.column.is-one-fifth-touch{flex:none;width:20%}.column.is-two-fifths-touch{flex:none;width:40%}.column.is-three-fifths-touch{flex:none;width:60%}.column.is-four-fifths-touch{flex:none;width:80%}.column.is-offset-three-quarters-touch{margin-left:75%}.column.is-offset-two-thirds-touch{margin-left:66.6666%}.column.is-offset-half-touch{margin-left:50%}.column.is-offset-one-third-touch{margin-left:33.3333%}.column.is-offset-one-quarter-touch{margin-left:25%}.column.is-offset-one-fifth-touch{margin-left:20%}.column.is-offset-two-fifths-touch{margin-left:40%}.column.is-offset-three-fifths-touch{margin-left:60%}.column.is-offset-four-fifths-touch{margin-left:80%}.column.is-0-touch{flex:none;width:0%}.column.is-offset-0-touch{margin-left:0}.column.is-1-touch{flex:none;width:8.33333%}.column.is-offset-1-touch{margin-left:8.33333%}.column.is-2-touch{flex:none;width:16.66667%}.column.is-offset-2-touch{margin-left:16.66667%}.column.is-3-touch{flex:none;width:25%}.column.is-offset-3-touch{margin-left:25%}.column.is-4-touch{flex:none;width:33.33333%}.column.is-offset-4-touch{margin-left:33.33333%}.column.is-5-touch{flex:none;width:41.66667%}.column.is-offset-5-touch{margin-left:41.66667%}.column.is-6-touch{flex:none;width:50%}.column.is-offset-6-touch{margin-left:50%}.column.is-7-touch{flex:none;width:58.33333%}.column.is-offset-7-touch{margin-left:58.33333%}.column.is-8-touch{flex:none;width:66.66667%}.column.is-offset-8-touch{margin-left:66.66667%}.column.is-9-touch{flex:none;width:75%}.column.is-offset-9-touch{margin-left:75%}.column.is-10-touch{flex:none;width:83.33333%}.column.is-offset-10-touch{margin-left:83.33333%}.column.is-11-touch{flex:none;width:91.66667%}.column.is-offset-11-touch{margin-left:91.66667%}.column.is-12-touch{flex:none;width:100%}.column.is-offset-12-touch{margin-left:100%}}@media screen and (min-width:1024px){.column.is-narrow-desktop{flex:none}.column.is-full-desktop{flex:none;width:100%}.column.is-three-quarters-desktop{flex:none;width:75%}.column.is-two-thirds-desktop{flex:none;width:66.6666%}.column.is-half-desktop{flex:none;width:50%}.column.is-one-third-desktop{flex:none;width:33.3333%}.column.is-one-quarter-desktop{flex:none;width:25%}.column.is-one-fifth-desktop{flex:none;width:20%}.column.is-two-fifths-desktop{flex:none;width:40%}.column.is-three-fifths-desktop{flex:none;width:60%}.column.is-four-fifths-desktop{flex:none;width:80%}.column.is-offset-three-quarters-desktop{margin-left:75%}.column.is-offset-two-thirds-desktop{margin-left:66.6666%}.column.is-offset-half-desktop{margin-left:50%}.column.is-offset-one-third-desktop{margin-left:33.3333%}.column.is-offset-one-quarter-desktop{margin-left:25%}.column.is-offset-one-fifth-desktop{margin-left:20%}.column.is-offset-two-fifths-desktop{margin-left:40%}.column.is-offset-three-fifths-desktop{margin-left:60%}.column.is-offset-four-fifths-desktop{margin-left:80%}.column.is-0-desktop{flex:none;width:0%}.column.is-offset-0-desktop{margin-left:0}.column.is-1-desktop{flex:none;width:8.33333%}.column.is-offset-1-desktop{margin-left:8.33333%}.column.is-2-desktop{flex:none;width:16.66667%}.column.is-offset-2-desktop{margin-left:16.66667%}.column.is-3-desktop{flex:none;width:25%}.column.is-offset-3-desktop{margin-left:25%}.column.is-4-desktop{flex:none;width:33.33333%}.column.is-offset-4-desktop{margin-left:33.33333%}.column.is-5-desktop{flex:none;width:41.66667%}.column.is-offset-5-desktop{margin-left:41.66667%}.column.is-6-desktop{flex:none;width:50%}.column.is-offset-6-desktop{margin-left:50%}.column.is-7-desktop{flex:none;width:58.33333%}.column.is-offset-7-desktop{margin-left:58.33333%}.column.is-8-desktop{flex:none;width:66.66667%}.column.is-offset-8-desktop{margin-left:66.66667%}.column.is-9-desktop{flex:none;width:75%}.column.is-offset-9-desktop{margin-left:75%}.column.is-10-desktop{flex:none;width:83.33333%}.column.is-offset-10-desktop{margin-left:83.33333%}.column.is-11-desktop{flex:none;width:91.66667%}.column.is-offset-11-desktop{margin-left:91.66667%}.column.is-12-desktop{flex:none;width:100%}.column.is-offset-12-desktop{margin-left:100%}}@media screen and (min-width:1216px){.column.is-narrow-widescreen{flex:none}.column.is-full-widescreen{flex:none;width:100%}.column.is-three-quarters-widescreen{flex:none;width:75%}.column.is-two-thirds-widescreen{flex:none;width:66.6666%}.column.is-half-widescreen{flex:none;width:50%}.column.is-one-third-widescreen{flex:none;width:33.3333%}.column.is-one-quarter-widescreen{flex:none;width:25%}.column.is-one-fifth-widescreen{flex:none;width:20%}.column.is-two-fifths-widescreen{flex:none;width:40%}.column.is-three-fifths-widescreen{flex:none;width:60%}.column.is-four-fifths-widescreen{flex:none;width:80%}.column.is-offset-three-quarters-widescreen{margin-left:75%}.column.is-offset-two-thirds-widescreen{margin-left:66.6666%}.column.is-offset-half-widescreen{margin-left:50%}.column.is-offset-one-third-widescreen{margin-left:33.3333%}.column.is-offset-one-quarter-widescreen{margin-left:25%}.column.is-offset-one-fifth-widescreen{margin-left:20%}.column.is-offset-two-fifths-widescreen{margin-left:40%}.column.is-offset-three-fifths-widescreen{margin-left:60%}.column.is-offset-four-fifths-widescreen{margin-left:80%}.column.is-0-widescreen{flex:none;width:0%}.column.is-offset-0-widescreen{margin-left:0}.column.is-1-widescreen{flex:none;width:8.33333%}.column.is-offset-1-widescreen{margin-left:8.33333%}.column.is-2-widescreen{flex:none;width:16.66667%}.column.is-offset-2-widescreen{margin-left:16.66667%}.column.is-3-widescreen{flex:none;width:25%}.column.is-offset-3-widescreen{margin-left:25%}.column.is-4-widescreen{flex:none;width:33.33333%}.column.is-offset-4-widescreen{margin-left:33.33333%}.column.is-5-widescreen{flex:none;width:41.66667%}.column.is-offset-5-widescreen{margin-left:41.66667%}.column.is-6-widescreen{flex:none;width:50%}.column.is-offset-6-widescreen{margin-left:50%}.column.is-7-widescreen{flex:none;width:58.33333%}.column.is-offset-7-widescreen{margin-left:58.33333%}.column.is-8-widescreen{flex:none;width:66.66667%}.column.is-offset-8-widescreen{margin-left:66.66667%}.column.is-9-widescreen{flex:none;width:75%}.column.is-offset-9-widescreen{margin-left:75%}.column.is-10-widescreen{flex:none;width:83.33333%}.column.is-offset-10-widescreen{margin-left:83.33333%}.column.is-11-widescreen{flex:none;width:91.66667%}.column.is-offset-11-widescreen{margin-left:91.66667%}.column.is-12-widescreen{flex:none;width:100%}.column.is-offset-12-widescreen{margin-left:100%}}@media screen and (min-width:1408px){.column.is-narrow-fullhd{flex:none}.column.is-full-fullhd{flex:none;width:100%}.column.is-three-quarters-fullhd{flex:none;width:75%}.column.is-two-thirds-fullhd{flex:none;width:66.6666%}.column.is-half-fullhd{flex:none;width:50%}.column.is-one-third-fullhd{flex:none;width:33.3333%}.column.is-one-quarter-fullhd{flex:none;width:25%}.column.is-one-fifth-fullhd{flex:none;width:20%}.column.is-two-fifths-fullhd{flex:none;width:40%}.column.is-three-fifths-fullhd{flex:none;width:60%}.column.is-four-fifths-fullhd{flex:none;width:80%}.column.is-offset-three-quarters-fullhd{margin-left:75%}.column.is-offset-two-thirds-fullhd{margin-left:66.6666%}.column.is-offset-half-fullhd{margin-left:50%}.column.is-offset-one-third-fullhd{margin-left:33.3333%}.column.is-offset-one-quarter-fullhd{margin-left:25%}.column.is-offset-one-fifth-fullhd{margin-left:20%}.column.is-offset-two-fifths-fullhd{margin-left:40%}.column.is-offset-three-fifths-fullhd{margin-left:60%}.column.is-offset-four-fifths-fullhd{margin-left:80%}.column.is-0-fullhd{flex:none;width:0%}.column.is-offset-0-fullhd{margin-left:0}.column.is-1-fullhd{flex:none;width:8.33333%}.column.is-offset-1-fullhd{margin-left:8.33333%}.column.is-2-fullhd{flex:none;width:16.66667%}.column.is-offset-2-fullhd{margin-left:16.66667%}.column.is-3-fullhd{flex:none;width:25%}.column.is-offset-3-fullhd{margin-left:25%}.column.is-4-fullhd{flex:none;width:33.33333%}.column.is-offset-4-fullhd{margin-left:33.33333%}.column.is-5-fullhd{flex:none;width:41.66667%}.column.is-offset-5-fullhd{margin-left:41.66667%}.column.is-6-fullhd{flex:none;width:50%}.column.is-offset-6-fullhd{margin-left:50%}.column.is-7-fullhd{flex:none;width:58.33333%}.column.is-offset-7-fullhd{margin-left:58.33333%}.column.is-8-fullhd{flex:none;width:66.66667%}.column.is-offset-8-fullhd{margin-left:66.66667%}.column.is-9-fullhd{flex:none;width:75%}.column.is-offset-9-fullhd{margin-left:75%}.column.is-10-fullhd{flex:none;width:83.33333%}.column.is-offset-10-fullhd{margin-left:83.33333%}.column.is-11-fullhd{flex:none;width:91.66667%}.column.is-offset-11-fullhd{margin-left:91.66667%}.column.is-12-fullhd{flex:none;width:100%}.column.is-offset-12-fullhd{margin-left:100%}}.columns{margin-left:-.75rem;margin-right:-.75rem;margin-top:-.75rem}.columns:last-child{margin-bottom:-.75rem}.columns:not(:last-child){margin-bottom:calc(1.5rem - .75rem)}.columns.is-centered{justify-content:center}.columns.is-gapless{margin-left:0;margin-right:0;margin-top:0}.columns.is-gapless>.column{margin:0;padding:0!important}.columns.is-gapless:not(:last-child){margin-bottom:1.5rem}.columns.is-gapless:last-child{margin-bottom:0}.columns.is-mobile{display:flex}.columns.is-multiline{flex-wrap:wrap}.columns.is-vcentered{align-items:center}@media screen and (min-width:769px),print{.columns:not(.is-desktop){display:flex}}@media screen and (min-width:1024px){.columns.is-desktop{display:flex}}.columns.is-variable{--columnGap:0.75rem;margin-left:calc(-1 * var(--columnGap));margin-right:calc(-1 * var(--columnGap))}.columns.is-variable .column{padding-left:var(--columnGap);padding-right:var(--columnGap)}.columns.is-variable.is-0{--columnGap:0rem}@media screen and (max-width:768px){.columns.is-variable.is-0-mobile{--columnGap:0rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-0-tablet{--columnGap:0rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-0-tablet-only{--columnGap:0rem}}@media screen and (max-width:1023px){.columns.is-variable.is-0-touch{--columnGap:0rem}}@media screen and (min-width:1024px){.columns.is-variable.is-0-desktop{--columnGap:0rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-0-desktop-only{--columnGap:0rem}}@media screen and (min-width:1216px){.columns.is-variable.is-0-widescreen{--columnGap:0rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-0-widescreen-only{--columnGap:0rem}}@media screen and (min-width:1408px){.columns.is-variable.is-0-fullhd{--columnGap:0rem}}.columns.is-variable.is-1{--columnGap:0.25rem}@media screen and (max-width:768px){.columns.is-variable.is-1-mobile{--columnGap:0.25rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-1-tablet{--columnGap:0.25rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-1-tablet-only{--columnGap:0.25rem}}@media screen and (max-width:1023px){.columns.is-variable.is-1-touch{--columnGap:0.25rem}}@media screen and (min-width:1024px){.columns.is-variable.is-1-desktop{--columnGap:0.25rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-1-desktop-only{--columnGap:0.25rem}}@media screen and (min-width:1216px){.columns.is-variable.is-1-widescreen{--columnGap:0.25rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-1-widescreen-only{--columnGap:0.25rem}}@media screen and (min-width:1408px){.columns.is-variable.is-1-fullhd{--columnGap:0.25rem}}.columns.is-variable.is-2{--columnGap:0.5rem}@media screen and (max-width:768px){.columns.is-variable.is-2-mobile{--columnGap:0.5rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-2-tablet{--columnGap:0.5rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-2-tablet-only{--columnGap:0.5rem}}@media screen and (max-width:1023px){.columns.is-variable.is-2-touch{--columnGap:0.5rem}}@media screen and (min-width:1024px){.columns.is-variable.is-2-desktop{--columnGap:0.5rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-2-desktop-only{--columnGap:0.5rem}}@media screen and (min-width:1216px){.columns.is-variable.is-2-widescreen{--columnGap:0.5rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-2-widescreen-only{--columnGap:0.5rem}}@media screen and (min-width:1408px){.columns.is-variable.is-2-fullhd{--columnGap:0.5rem}}.columns.is-variable.is-3{--columnGap:0.75rem}@media screen and (max-width:768px){.columns.is-variable.is-3-mobile{--columnGap:0.75rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-3-tablet{--columnGap:0.75rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-3-tablet-only{--columnGap:0.75rem}}@media screen and (max-width:1023px){.columns.is-variable.is-3-touch{--columnGap:0.75rem}}@media screen and (min-width:1024px){.columns.is-variable.is-3-desktop{--columnGap:0.75rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-3-desktop-only{--columnGap:0.75rem}}@media screen and (min-width:1216px){.columns.is-variable.is-3-widescreen{--columnGap:0.75rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-3-widescreen-only{--columnGap:0.75rem}}@media screen and (min-width:1408px){.columns.is-variable.is-3-fullhd{--columnGap:0.75rem}}.columns.is-variable.is-4{--columnGap:1rem}@media screen and (max-width:768px){.columns.is-variable.is-4-mobile{--columnGap:1rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-4-tablet{--columnGap:1rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-4-tablet-only{--columnGap:1rem}}@media screen and (max-width:1023px){.columns.is-variable.is-4-touch{--columnGap:1rem}}@media screen and (min-width:1024px){.columns.is-variable.is-4-desktop{--columnGap:1rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-4-desktop-only{--columnGap:1rem}}@media screen and (min-width:1216px){.columns.is-variable.is-4-widescreen{--columnGap:1rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-4-widescreen-only{--columnGap:1rem}}@media screen and (min-width:1408px){.columns.is-variable.is-4-fullhd{--columnGap:1rem}}.columns.is-variable.is-5{--columnGap:1.25rem}@media screen and (max-width:768px){.columns.is-variable.is-5-mobile{--columnGap:1.25rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-5-tablet{--columnGap:1.25rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-5-tablet-only{--columnGap:1.25rem}}@media screen and (max-width:1023px){.columns.is-variable.is-5-touch{--columnGap:1.25rem}}@media screen and (min-width:1024px){.columns.is-variable.is-5-desktop{--columnGap:1.25rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-5-desktop-only{--columnGap:1.25rem}}@media screen and (min-width:1216px){.columns.is-variable.is-5-widescreen{--columnGap:1.25rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-5-widescreen-only{--columnGap:1.25rem}}@media screen and (min-width:1408px){.columns.is-variable.is-5-fullhd{--columnGap:1.25rem}}.columns.is-variable.is-6{--columnGap:1.5rem}@media screen and (max-width:768px){.columns.is-variable.is-6-mobile{--columnGap:1.5rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-6-tablet{--columnGap:1.5rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-6-tablet-only{--columnGap:1.5rem}}@media screen and (max-width:1023px){.columns.is-variable.is-6-touch{--columnGap:1.5rem}}@media screen and (min-width:1024px){.columns.is-variable.is-6-desktop{--columnGap:1.5rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-6-desktop-only{--columnGap:1.5rem}}@media screen and (min-width:1216px){.columns.is-variable.is-6-widescreen{--columnGap:1.5rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-6-widescreen-only{--columnGap:1.5rem}}@media screen and (min-width:1408px){.columns.is-variable.is-6-fullhd{--columnGap:1.5rem}}.columns.is-variable.is-7{--columnGap:1.75rem}@media screen and (max-width:768px){.columns.is-variable.is-7-mobile{--columnGap:1.75rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-7-tablet{--columnGap:1.75rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-7-tablet-only{--columnGap:1.75rem}}@media screen and (max-width:1023px){.columns.is-variable.is-7-touch{--columnGap:1.75rem}}@media screen and (min-width:1024px){.columns.is-variable.is-7-desktop{--columnGap:1.75rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-7-desktop-only{--columnGap:1.75rem}}@media screen and (min-width:1216px){.columns.is-variable.is-7-widescreen{--columnGap:1.75rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-7-widescreen-only{--columnGap:1.75rem}}@media screen and (min-width:1408px){.columns.is-variable.is-7-fullhd{--columnGap:1.75rem}}.columns.is-variable.is-8{--columnGap:2rem}@media screen and (max-width:768px){.columns.is-variable.is-8-mobile{--columnGap:2rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-8-tablet{--columnGap:2rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-8-tablet-only{--columnGap:2rem}}@media screen and (max-width:1023px){.columns.is-variable.is-8-touch{--columnGap:2rem}}@media screen and (min-width:1024px){.columns.is-variable.is-8-desktop{--columnGap:2rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-8-desktop-only{--columnGap:2rem}}@media screen and (min-width:1216px){.columns.is-variable.is-8-widescreen{--columnGap:2rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-8-widescreen-only{--columnGap:2rem}}@media screen and (min-width:1408px){.columns.is-variable.is-8-fullhd{--columnGap:2rem}}.tile{align-items:stretch;display:block;flex-basis:0;flex-grow:1;flex-shrink:1;min-height:-webkit-min-content;min-height:-moz-min-content;min-height:min-content}.tile.is-ancestor{margin-left:-.75rem;margin-right:-.75rem;margin-top:-.75rem}.tile.is-ancestor:last-child{margin-bottom:-.75rem}.tile.is-ancestor:not(:last-child){margin-bottom:.75rem}.tile.is-child{margin:0!important}.tile.is-parent{padding:.75rem}.tile.is-vertical{flex-direction:column}.tile.is-vertical>.tile.is-child:not(:last-child){margin-bottom:1.5rem!important}@media screen and (min-width:769px),print{.tile:not(.is-child){display:flex}.tile.is-1{flex:none;width:8.33333%}.tile.is-2{flex:none;width:16.66667%}.tile.is-3{flex:none;width:25%}.tile.is-4{flex:none;width:33.33333%}.tile.is-5{flex:none;width:41.66667%}.tile.is-6{flex:none;width:50%}.tile.is-7{flex:none;width:58.33333%}.tile.is-8{flex:none;width:66.66667%}.tile.is-9{flex:none;width:75%}.tile.is-10{flex:none;width:83.33333%}.tile.is-11{flex:none;width:91.66667%}.tile.is-12{flex:none;width:100%}}.has-text-white{color:#fff!important}a.has-text-white:focus,a.has-text-white:hover{color:#e6e6e6!important}.has-background-white{background-color:#fff!important}.has-text-black{color:#0a0a0a!important}a.has-text-black:focus,a.has-text-black:hover{color:#000!important}.has-background-black{background-color:#0a0a0a!important}.has-text-light{color:#f5f5f5!important}a.has-text-light:focus,a.has-text-light:hover{color:#dbdbdb!important}.has-background-light{background-color:#f5f5f5!important}.has-text-dark{color:#363636!important}a.has-text-dark:focus,a.has-text-dark:hover{color:#1c1c1c!important}.has-background-dark{background-color:#363636!important}.has-text-primary{color:#00d1b2!important}a.has-text-primary:focus,a.has-text-primary:hover{color:#009e86!important}.has-background-primary{background-color:#00d1b2!important}.has-text-primary-light{color:#ebfffc!important}a.has-text-primary-light:focus,a.has-text-primary-light:hover{color:#b8fff4!important}.has-background-primary-light{background-color:#ebfffc!important}.has-text-primary-dark{color:#00947e!important}a.has-text-primary-dark:focus,a.has-text-primary-dark:hover{color:#00c7a9!important}.has-background-primary-dark{background-color:#00947e!important}.has-text-link{color:#3273dc!important}a.has-text-link:focus,a.has-text-link:hover{color:#205bbc!important}.has-background-link{background-color:#3273dc!important}.has-text-link-light{color:#eef3fc!important}a.has-text-link-light:focus,a.has-text-link-light:hover{color:#c2d5f5!important}.has-background-link-light{background-color:#eef3fc!important}.has-text-link-dark{color:#2160c4!important}a.has-text-link-dark:focus,a.has-text-link-dark:hover{color:#3b79de!important}.has-background-link-dark{background-color:#2160c4!important}.has-text-info{color:#3298dc!important}a.has-text-info:focus,a.has-text-info:hover{color:#207dbc!important}.has-background-info{background-color:#3298dc!important}.has-text-info-light{color:#eef6fc!important}a.has-text-info-light:focus,a.has-text-info-light:hover{color:#c2e0f5!important}.has-background-info-light{background-color:#eef6fc!important}.has-text-info-dark{color:#1d72aa!important}a.has-text-info-dark:focus,a.has-text-info-dark:hover{color:#248fd6!important}.has-background-info-dark{background-color:#1d72aa!important}.has-text-success{color:#48c774!important}a.has-text-success:focus,a.has-text-success:hover{color:#34a85c!important}.has-background-success{background-color:#48c774!important}.has-text-success-light{color:#effaf3!important}a.has-text-success-light:focus,a.has-text-success-light:hover{color:#c8eed6!important}.has-background-success-light{background-color:#effaf3!important}.has-text-success-dark{color:#257942!important}a.has-text-success-dark:focus,a.has-text-success-dark:hover{color:#31a058!important}.has-background-success-dark{background-color:#257942!important}.has-text-warning{color:#ffdd57!important}a.has-text-warning:focus,a.has-text-warning:hover{color:#ffd324!important}.has-background-warning{background-color:#ffdd57!important}.has-text-warning-light{color:#fffbeb!important}a.has-text-warning-light:focus,a.has-text-warning-light:hover{color:#fff1b8!important}.has-background-warning-light{background-color:#fffbeb!important}.has-text-warning-dark{color:#947600!important}a.has-text-warning-dark:focus,a.has-text-warning-dark:hover{color:#c79f00!important}.has-background-warning-dark{background-color:#947600!important}.has-text-danger{color:#f14668!important}a.has-text-danger:focus,a.has-text-danger:hover{color:#ee1742!important}.has-background-danger{background-color:#f14668!important}.has-text-danger-light{color:#feecf0!important}a.has-text-danger-light:focus,a.has-text-danger-light:hover{color:#fabdc9!important}.has-background-danger-light{background-color:#feecf0!important}.has-text-danger-dark{color:#cc0f35!important}a.has-text-danger-dark:focus,a.has-text-danger-dark:hover{color:#ee2049!important}.has-background-danger-dark{background-color:#cc0f35!important}.has-text-black-bis{color:#121212!important}.has-background-black-bis{background-color:#121212!important}.has-text-black-ter{color:#242424!important}.has-background-black-ter{background-color:#242424!important}.has-text-grey-darker{color:#363636!important}.has-background-grey-darker{background-color:#363636!important}.has-text-grey-dark{color:#4a4a4a!important}.has-background-grey-dark{background-color:#4a4a4a!important}.has-text-grey{color:#7a7a7a!important}.has-background-grey{background-color:#7a7a7a!important}.has-text-grey-light{color:#b5b5b5!important}.has-background-grey-light{background-color:#b5b5b5!important}.has-text-grey-lighter{color:#dbdbdb!important}.has-background-grey-lighter{background-color:#dbdbdb!important}.has-text-white-ter{color:#f5f5f5!important}.has-background-white-ter{background-color:#f5f5f5!important}.has-text-white-bis{color:#fafafa!important}.has-background-white-bis{background-color:#fafafa!important}.is-flex-direction-row{flex-direction:row!important}.is-flex-direction-row-reverse{flex-direction:row-reverse!important}.is-flex-direction-column{flex-direction:column!important}.is-flex-direction-column-reverse{flex-direction:column-reverse!important}.is-flex-wrap-nowrap{flex-wrap:nowrap!important}.is-flex-wrap-wrap{flex-wrap:wrap!important}.is-flex-wrap-wrap-reverse{flex-wrap:wrap-reverse!important}.is-justify-content-flex-start{justify-content:flex-start!important}.is-justify-content-flex-end{justify-content:flex-end!important}.is-justify-content-center{justify-content:center!important}.is-justify-content-space-between{justify-content:space-between!important}.is-justify-content-space-around{justify-content:space-around!important}.is-justify-content-space-evenly{justify-content:space-evenly!important}.is-justify-content-start{justify-content:start!important}.is-justify-content-end{justify-content:end!important}.is-justify-content-left{justify-content:left!important}.is-justify-content-right{justify-content:right!important}.is-align-content-flex-start{align-content:flex-start!important}.is-align-content-flex-end{align-content:flex-end!important}.is-align-content-center{align-content:center!important}.is-align-content-space-between{align-content:space-between!important}.is-align-content-space-around{align-content:space-around!important}.is-align-content-space-evenly{align-content:space-evenly!important}.is-align-content-stretch{align-content:stretch!important}.is-align-content-start{align-content:start!important}.is-align-content-end{align-content:end!important}.is-align-content-baseline{align-content:baseline!important}.is-align-items-stretch{align-items:stretch!important}.is-align-items-flex-start{align-items:flex-start!important}.is-align-items-flex-end{align-items:flex-end!important}.is-align-items-center{align-items:center!important}.is-align-items-baseline{align-items:baseline!important}.is-align-items-start{align-items:start!important}.is-align-items-end{align-items:end!important}.is-align-items-self-start{align-items:self-start!important}.is-align-items-self-end{align-items:self-end!important}.is-align-self-auto{align-self:auto!important}.is-align-self-flex-start{align-self:flex-start!important}.is-align-self-flex-end{align-self:flex-end!important}.is-align-self-center{align-self:center!important}.is-align-self-baseline{align-self:baseline!important}.is-align-self-stretch{align-self:stretch!important}.is-flex-grow-0{flex-grow:0!important}.is-flex-grow-1{flex-grow:1!important}.is-flex-grow-2{flex-grow:2!important}.is-flex-grow-3{flex-grow:3!important}.is-flex-grow-4{flex-grow:4!important}.is-flex-grow-5{flex-grow:5!important}.is-flex-shrink-0{flex-shrink:0!important}.is-flex-shrink-1{flex-shrink:1!important}.is-flex-shrink-2{flex-shrink:2!important}.is-flex-shrink-3{flex-shrink:3!important}.is-flex-shrink-4{flex-shrink:4!important}.is-flex-shrink-5{flex-shrink:5!important}.is-clearfix::after{clear:both;content:" ";display:table}.is-pulled-left{float:left!important}.is-pulled-right{float:right!important}.is-radiusless{border-radius:0!important}.is-shadowless{box-shadow:none!important}.is-clickable{cursor:pointer!important}.is-clipped{overflow:hidden!important}.is-relative{position:relative!important}.is-marginless{margin:0!important}.is-paddingless{padding:0!important}.m-0{margin:0!important}.mt-0{margin-top:0!important}.mr-0{margin-right:0!important}.mb-0{margin-bottom:0!important}.ml-0{margin-left:0!important}.mx-0{margin-left:0!important;margin-right:0!important}.my-0{margin-top:0!important;margin-bottom:0!important}.m-1{margin:.25rem!important}.mt-1{margin-top:.25rem!important}.mr-1{margin-right:.25rem!important}.mb-1{margin-bottom:.25rem!important}.ml-1{margin-left:.25rem!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.m-2{margin:.5rem!important}.mt-2{margin-top:.5rem!important}.mr-2{margin-right:.5rem!important}.mb-2{margin-bottom:.5rem!important}.ml-2{margin-left:.5rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.m-3{margin:.75rem!important}.mt-3{margin-top:.75rem!important}.mr-3{margin-right:.75rem!important}.mb-3{margin-bottom:.75rem!important}.ml-3{margin-left:.75rem!important}.mx-3{margin-left:.75rem!important;margin-right:.75rem!important}.my-3{margin-top:.75rem!important;margin-bottom:.75rem!important}.m-4{margin:1rem!important}.mt-4{margin-top:1rem!important}.mr-4{margin-right:1rem!important}.mb-4{margin-bottom:1rem!important}.ml-4{margin-left:1rem!important}.mx-4{margin-left:1rem!important;margin-right:1rem!important}.my-4{margin-top:1rem!important;margin-bottom:1rem!important}.m-5{margin:1.5rem!important}.mt-5{margin-top:1.5rem!important}.mr-5{margin-right:1.5rem!important}.mb-5{margin-bottom:1.5rem!important}.ml-5{margin-left:1.5rem!important}.mx-5{margin-left:1.5rem!important;margin-right:1.5rem!important}.my-5{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.m-6{margin:3rem!important}.mt-6{margin-top:3rem!important}.mr-6{margin-right:3rem!important}.mb-6{margin-bottom:3rem!important}.ml-6{margin-left:3rem!important}.mx-6{margin-left:3rem!important;margin-right:3rem!important}.my-6{margin-top:3rem!important;margin-bottom:3rem!important}.p-0{padding:0!important}.pt-0{padding-top:0!important}.pr-0{padding-right:0!important}.pb-0{padding-bottom:0!important}.pl-0{padding-left:0!important}.px-0{padding-left:0!important;padding-right:0!important}.py-0{padding-top:0!important;padding-bottom:0!important}.p-1{padding:.25rem!important}.pt-1{padding-top:.25rem!important}.pr-1{padding-right:.25rem!important}.pb-1{padding-bottom:.25rem!important}.pl-1{padding-left:.25rem!important}.px-1{padding-left:.25rem!important;padding-right:.25rem!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.p-2{padding:.5rem!important}.pt-2{padding-top:.5rem!important}.pr-2{padding-right:.5rem!important}.pb-2{padding-bottom:.5rem!important}.pl-2{padding-left:.5rem!important}.px-2{padding-left:.5rem!important;padding-right:.5rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.p-3{padding:.75rem!important}.pt-3{padding-top:.75rem!important}.pr-3{padding-right:.75rem!important}.pb-3{padding-bottom:.75rem!important}.pl-3{padding-left:.75rem!important}.px-3{padding-left:.75rem!important;padding-right:.75rem!important}.py-3{padding-top:.75rem!important;padding-bottom:.75rem!important}.p-4{padding:1rem!important}.pt-4{padding-top:1rem!important}.pr-4{padding-right:1rem!important}.pb-4{padding-bottom:1rem!important}.pl-4{padding-left:1rem!important}.px-4{padding-left:1rem!important;padding-right:1rem!important}.py-4{padding-top:1rem!important;padding-bottom:1rem!important}.p-5{padding:1.5rem!important}.pt-5{padding-top:1.5rem!important}.pr-5{padding-right:1.5rem!important}.pb-5{padding-bottom:1.5rem!important}.pl-5{padding-left:1.5rem!important}.px-5{padding-left:1.5rem!important;padding-right:1.5rem!important}.py-5{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.p-6{padding:3rem!important}.pt-6{padding-top:3rem!important}.pr-6{padding-right:3rem!important}.pb-6{padding-bottom:3rem!important}.pl-6{padding-left:3rem!important}.px-6{padding-left:3rem!important;padding-right:3rem!important}.py-6{padding-top:3rem!important;padding-bottom:3rem!important}.is-size-1{font-size:3rem!important}.is-size-2{font-size:2.5rem!important}.is-size-3{font-size:2rem!important}.is-size-4{font-size:1.5rem!important}.is-size-5{font-size:1.25rem!important}.is-size-6{font-size:1rem!important}.is-size-7{font-size:.75rem!important}@media screen and (max-width:768px){.is-size-1-mobile{font-size:3rem!important}.is-size-2-mobile{font-size:2.5rem!important}.is-size-3-mobile{font-size:2rem!important}.is-size-4-mobile{font-size:1.5rem!important}.is-size-5-mobile{font-size:1.25rem!important}.is-size-6-mobile{font-size:1rem!important}.is-size-7-mobile{font-size:.75rem!important}}@media screen and (min-width:769px),print{.is-size-1-tablet{font-size:3rem!important}.is-size-2-tablet{font-size:2.5rem!important}.is-size-3-tablet{font-size:2rem!important}.is-size-4-tablet{font-size:1.5rem!important}.is-size-5-tablet{font-size:1.25rem!important}.is-size-6-tablet{font-size:1rem!important}.is-size-7-tablet{font-size:.75rem!important}}@media screen and (max-width:1023px){.is-size-1-touch{font-size:3rem!important}.is-size-2-touch{font-size:2.5rem!important}.is-size-3-touch{font-size:2rem!important}.is-size-4-touch{font-size:1.5rem!important}.is-size-5-touch{font-size:1.25rem!important}.is-size-6-touch{font-size:1rem!important}.is-size-7-touch{font-size:.75rem!important}}@media screen and (min-width:1024px){.is-size-1-desktop{font-size:3rem!important}.is-size-2-desktop{font-size:2.5rem!important}.is-size-3-desktop{font-size:2rem!important}.is-size-4-desktop{font-size:1.5rem!important}.is-size-5-desktop{font-size:1.25rem!important}.is-size-6-desktop{font-size:1rem!important}.is-size-7-desktop{font-size:.75rem!important}}@media screen and (min-width:1216px){.is-size-1-widescreen{font-size:3rem!important}.is-size-2-widescreen{font-size:2.5rem!important}.is-size-3-widescreen{font-size:2rem!important}.is-size-4-widescreen{font-size:1.5rem!important}.is-size-5-widescreen{font-size:1.25rem!important}.is-size-6-widescreen{font-size:1rem!important}.is-size-7-widescreen{font-size:.75rem!important}}@media screen and (min-width:1408px){.is-size-1-fullhd{font-size:3rem!important}.is-size-2-fullhd{font-size:2.5rem!important}.is-size-3-fullhd{font-size:2rem!important}.is-size-4-fullhd{font-size:1.5rem!important}.is-size-5-fullhd{font-size:1.25rem!important}.is-size-6-fullhd{font-size:1rem!important}.is-size-7-fullhd{font-size:.75rem!important}}.has-text-centered{text-align:center!important}.has-text-justified{text-align:justify!important}.has-text-left{text-align:left!important}.has-text-right{text-align:right!important}@media screen and (max-width:768px){.has-text-centered-mobile{text-align:center!important}}@media screen and (min-width:769px),print{.has-text-centered-tablet{text-align:center!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-centered-tablet-only{text-align:center!important}}@media screen and (max-width:1023px){.has-text-centered-touch{text-align:center!important}}@media screen and (min-width:1024px){.has-text-centered-desktop{text-align:center!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-centered-desktop-only{text-align:center!important}}@media screen and (min-width:1216px){.has-text-centered-widescreen{text-align:center!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-centered-widescreen-only{text-align:center!important}}@media screen and (min-width:1408px){.has-text-centered-fullhd{text-align:center!important}}@media screen and (max-width:768px){.has-text-justified-mobile{text-align:justify!important}}@media screen and (min-width:769px),print{.has-text-justified-tablet{text-align:justify!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-justified-tablet-only{text-align:justify!important}}@media screen and (max-width:1023px){.has-text-justified-touch{text-align:justify!important}}@media screen and (min-width:1024px){.has-text-justified-desktop{text-align:justify!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-justified-desktop-only{text-align:justify!important}}@media screen and (min-width:1216px){.has-text-justified-widescreen{text-align:justify!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-justified-widescreen-only{text-align:justify!important}}@media screen and (min-width:1408px){.has-text-justified-fullhd{text-align:justify!important}}@media screen and (max-width:768px){.has-text-left-mobile{text-align:left!important}}@media screen and (min-width:769px),print{.has-text-left-tablet{text-align:left!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-left-tablet-only{text-align:left!important}}@media screen and (max-width:1023px){.has-text-left-touch{text-align:left!important}}@media screen and (min-width:1024px){.has-text-left-desktop{text-align:left!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-left-desktop-only{text-align:left!important}}@media screen and (min-width:1216px){.has-text-left-widescreen{text-align:left!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-left-widescreen-only{text-align:left!important}}@media screen and (min-width:1408px){.has-text-left-fullhd{text-align:left!important}}@media screen and (max-width:768px){.has-text-right-mobile{text-align:right!important}}@media screen and (min-width:769px),print{.has-text-right-tablet{text-align:right!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-right-tablet-only{text-align:right!important}}@media screen and (max-width:1023px){.has-text-right-touch{text-align:right!important}}@media screen and (min-width:1024px){.has-text-right-desktop{text-align:right!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-right-desktop-only{text-align:right!important}}@media screen and (min-width:1216px){.has-text-right-widescreen{text-align:right!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-right-widescreen-only{text-align:right!important}}@media screen and (min-width:1408px){.has-text-right-fullhd{text-align:right!important}}.is-capitalized{text-transform:capitalize!important}.is-lowercase{text-transform:lowercase!important}.is-uppercase{text-transform:uppercase!important}.is-italic{font-style:italic!important}.has-text-weight-light{font-weight:300!important}.has-text-weight-normal{font-weight:400!important}.has-text-weight-medium{font-weight:500!important}.has-text-weight-semibold{font-weight:600!important}.has-text-weight-bold{font-weight:700!important}.is-family-primary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-secondary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-sans-serif{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-monospace{font-family:monospace!important}.is-family-code{font-family:monospace!important}.is-block{display:block!important}@media screen and (max-width:768px){.is-block-mobile{display:block!important}}@media screen and (min-width:769px),print{.is-block-tablet{display:block!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-block-tablet-only{display:block!important}}@media screen and (max-width:1023px){.is-block-touch{display:block!important}}@media screen and (min-width:1024px){.is-block-desktop{display:block!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-block-desktop-only{display:block!important}}@media screen and (min-width:1216px){.is-block-widescreen{display:block!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-block-widescreen-only{display:block!important}}@media screen and (min-width:1408px){.is-block-fullhd{display:block!important}}.is-flex{display:flex!important}@media screen and (max-width:768px){.is-flex-mobile{display:flex!important}}@media screen and (min-width:769px),print{.is-flex-tablet{display:flex!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-flex-tablet-only{display:flex!important}}@media screen and (max-width:1023px){.is-flex-touch{display:flex!important}}@media screen and (min-width:1024px){.is-flex-desktop{display:flex!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-flex-desktop-only{display:flex!important}}@media screen and (min-width:1216px){.is-flex-widescreen{display:flex!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-flex-widescreen-only{display:flex!important}}@media screen and (min-width:1408px){.is-flex-fullhd{display:flex!important}}.is-inline{display:inline!important}@media screen and (max-width:768px){.is-inline-mobile{display:inline!important}}@media screen and (min-width:769px),print{.is-inline-tablet{display:inline!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-tablet-only{display:inline!important}}@media screen and (max-width:1023px){.is-inline-touch{display:inline!important}}@media screen and (min-width:1024px){.is-inline-desktop{display:inline!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-desktop-only{display:inline!important}}@media screen and (min-width:1216px){.is-inline-widescreen{display:inline!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-widescreen-only{display:inline!important}}@media screen and (min-width:1408px){.is-inline-fullhd{display:inline!important}}.is-inline-block{display:inline-block!important}@media screen and (max-width:768px){.is-inline-block-mobile{display:inline-block!important}}@media screen and (min-width:769px),print{.is-inline-block-tablet{display:inline-block!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-block-tablet-only{display:inline-block!important}}@media screen and (max-width:1023px){.is-inline-block-touch{display:inline-block!important}}@media screen and (min-width:1024px){.is-inline-block-desktop{display:inline-block!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-block-desktop-only{display:inline-block!important}}@media screen and (min-width:1216px){.is-inline-block-widescreen{display:inline-block!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-block-widescreen-only{display:inline-block!important}}@media screen and (min-width:1408px){.is-inline-block-fullhd{display:inline-block!important}}.is-inline-flex{display:inline-flex!important}@media screen and (max-width:768px){.is-inline-flex-mobile{display:inline-flex!important}}@media screen and (min-width:769px),print{.is-inline-flex-tablet{display:inline-flex!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-flex-tablet-only{display:inline-flex!important}}@media screen and (max-width:1023px){.is-inline-flex-touch{display:inline-flex!important}}@media screen and (min-width:1024px){.is-inline-flex-desktop{display:inline-flex!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-flex-desktop-only{display:inline-flex!important}}@media screen and (min-width:1216px){.is-inline-flex-widescreen{display:inline-flex!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-flex-widescreen-only{display:inline-flex!important}}@media screen and (min-width:1408px){.is-inline-flex-fullhd{display:inline-flex!important}}.is-hidden{display:none!important}.is-sr-only{border:none!important;clip:rect(0,0,0,0)!important;height:.01em!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:.01em!important}@media screen and (max-width:768px){.is-hidden-mobile{display:none!important}}@media screen and (min-width:769px),print{.is-hidden-tablet{display:none!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-hidden-tablet-only{display:none!important}}@media screen and (max-width:1023px){.is-hidden-touch{display:none!important}}@media screen and (min-width:1024px){.is-hidden-desktop{display:none!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-hidden-desktop-only{display:none!important}}@media screen and (min-width:1216px){.is-hidden-widescreen{display:none!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-hidden-widescreen-only{display:none!important}}@media screen and (min-width:1408px){.is-hidden-fullhd{display:none!important}}.is-invisible{visibility:hidden!important}@media screen and (max-width:768px){.is-invisible-mobile{visibility:hidden!important}}@media screen and (min-width:769px),print{.is-invisible-tablet{visibility:hidden!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-invisible-tablet-only{visibility:hidden!important}}@media screen and (max-width:1023px){.is-invisible-touch{visibility:hidden!important}}@media screen and (min-width:1024px){.is-invisible-desktop{visibility:hidden!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-invisible-desktop-only{visibility:hidden!important}}@media screen and (min-width:1216px){.is-invisible-widescreen{visibility:hidden!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-invisible-widescreen-only{visibility:hidden!important}}@media screen and (min-width:1408px){.is-invisible-fullhd{visibility:hidden!important}}.hero{align-items:stretch;display:flex;flex-direction:column;justify-content:space-between}.hero .navbar{background:0 0}.hero .tabs ul{border-bottom:none}.hero.is-white{background-color:#fff;color:#0a0a0a}.hero.is-white a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-white strong{color:inherit}.hero.is-white .title{color:#0a0a0a}.hero.is-white .subtitle{color:rgba(10,10,10,.9)}.hero.is-white .subtitle a:not(.button),.hero.is-white .subtitle strong{color:#0a0a0a}@media screen and (max-width:1023px){.hero.is-white .navbar-menu{background-color:#fff}}.hero.is-white .navbar-item,.hero.is-white .navbar-link{color:rgba(10,10,10,.7)}.hero.is-white .navbar-link.is-active,.hero.is-white .navbar-link:hover,.hero.is-white a.navbar-item.is-active,.hero.is-white a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.hero.is-white .tabs a{color:#0a0a0a;opacity:.9}.hero.is-white .tabs a:hover{opacity:1}.hero.is-white .tabs li.is-active a{opacity:1}.hero.is-white .tabs.is-boxed a,.hero.is-white .tabs.is-toggle a{color:#0a0a0a}.hero.is-white .tabs.is-boxed a:hover,.hero.is-white .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-white .tabs.is-boxed li.is-active a,.hero.is-white .tabs.is-boxed li.is-active a:hover,.hero.is-white .tabs.is-toggle li.is-active a,.hero.is-white .tabs.is-toggle li.is-active a:hover{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.hero.is-white.is-bold{background-image:linear-gradient(141deg,#e6e6e6 0,#fff 71%,#fff 100%)}@media screen and (max-width:768px){.hero.is-white.is-bold .navbar-menu{background-image:linear-gradient(141deg,#e6e6e6 0,#fff 71%,#fff 100%)}}.hero.is-black{background-color:#0a0a0a;color:#fff}.hero.is-black a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-black strong{color:inherit}.hero.is-black .title{color:#fff}.hero.is-black .subtitle{color:rgba(255,255,255,.9)}.hero.is-black .subtitle a:not(.button),.hero.is-black .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-black .navbar-menu{background-color:#0a0a0a}}.hero.is-black .navbar-item,.hero.is-black .navbar-link{color:rgba(255,255,255,.7)}.hero.is-black .navbar-link.is-active,.hero.is-black .navbar-link:hover,.hero.is-black a.navbar-item.is-active,.hero.is-black a.navbar-item:hover{background-color:#000;color:#fff}.hero.is-black .tabs a{color:#fff;opacity:.9}.hero.is-black .tabs a:hover{opacity:1}.hero.is-black .tabs li.is-active a{opacity:1}.hero.is-black .tabs.is-boxed a,.hero.is-black .tabs.is-toggle a{color:#fff}.hero.is-black .tabs.is-boxed a:hover,.hero.is-black .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-black .tabs.is-boxed li.is-active a,.hero.is-black .tabs.is-boxed li.is-active a:hover,.hero.is-black .tabs.is-toggle li.is-active a,.hero.is-black .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#0a0a0a}.hero.is-black.is-bold{background-image:linear-gradient(141deg,#000 0,#0a0a0a 71%,#181616 100%)}@media screen and (max-width:768px){.hero.is-black.is-bold .navbar-menu{background-image:linear-gradient(141deg,#000 0,#0a0a0a 71%,#181616 100%)}}.hero.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.hero.is-light a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-light strong{color:inherit}.hero.is-light .title{color:rgba(0,0,0,.7)}.hero.is-light .subtitle{color:rgba(0,0,0,.9)}.hero.is-light .subtitle a:not(.button),.hero.is-light .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width:1023px){.hero.is-light .navbar-menu{background-color:#f5f5f5}}.hero.is-light .navbar-item,.hero.is-light .navbar-link{color:rgba(0,0,0,.7)}.hero.is-light .navbar-link.is-active,.hero.is-light .navbar-link:hover,.hero.is-light a.navbar-item.is-active,.hero.is-light a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.hero.is-light .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-light .tabs a:hover{opacity:1}.hero.is-light .tabs li.is-active a{opacity:1}.hero.is-light .tabs.is-boxed a,.hero.is-light .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-light .tabs.is-boxed a:hover,.hero.is-light .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-light .tabs.is-boxed li.is-active a,.hero.is-light .tabs.is-boxed li.is-active a:hover,.hero.is-light .tabs.is-toggle li.is-active a,.hero.is-light .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:#f5f5f5}.hero.is-light.is-bold{background-image:linear-gradient(141deg,#dfd8d9 0,#f5f5f5 71%,#fff 100%)}@media screen and (max-width:768px){.hero.is-light.is-bold .navbar-menu{background-image:linear-gradient(141deg,#dfd8d9 0,#f5f5f5 71%,#fff 100%)}}.hero.is-dark{background-color:#363636;color:#fff}.hero.is-dark a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-dark strong{color:inherit}.hero.is-dark .title{color:#fff}.hero.is-dark .subtitle{color:rgba(255,255,255,.9)}.hero.is-dark .subtitle a:not(.button),.hero.is-dark .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-dark .navbar-menu{background-color:#363636}}.hero.is-dark .navbar-item,.hero.is-dark .navbar-link{color:rgba(255,255,255,.7)}.hero.is-dark .navbar-link.is-active,.hero.is-dark .navbar-link:hover,.hero.is-dark a.navbar-item.is-active,.hero.is-dark a.navbar-item:hover{background-color:#292929;color:#fff}.hero.is-dark .tabs a{color:#fff;opacity:.9}.hero.is-dark .tabs a:hover{opacity:1}.hero.is-dark .tabs li.is-active a{opacity:1}.hero.is-dark .tabs.is-boxed a,.hero.is-dark .tabs.is-toggle a{color:#fff}.hero.is-dark .tabs.is-boxed a:hover,.hero.is-dark .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-dark .tabs.is-boxed li.is-active a,.hero.is-dark .tabs.is-boxed li.is-active a:hover,.hero.is-dark .tabs.is-toggle li.is-active a,.hero.is-dark .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#363636}.hero.is-dark.is-bold{background-image:linear-gradient(141deg,#1f191a 0,#363636 71%,#46403f 100%)}@media screen and (max-width:768px){.hero.is-dark.is-bold .navbar-menu{background-image:linear-gradient(141deg,#1f191a 0,#363636 71%,#46403f 100%)}}.hero.is-primary{background-color:#00d1b2;color:#fff}.hero.is-primary a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-primary strong{color:inherit}.hero.is-primary .title{color:#fff}.hero.is-primary .subtitle{color:rgba(255,255,255,.9)}.hero.is-primary .subtitle a:not(.button),.hero.is-primary .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-primary .navbar-menu{background-color:#00d1b2}}.hero.is-primary .navbar-item,.hero.is-primary .navbar-link{color:rgba(255,255,255,.7)}.hero.is-primary .navbar-link.is-active,.hero.is-primary .navbar-link:hover,.hero.is-primary a.navbar-item.is-active,.hero.is-primary a.navbar-item:hover{background-color:#00b89c;color:#fff}.hero.is-primary .tabs a{color:#fff;opacity:.9}.hero.is-primary .tabs a:hover{opacity:1}.hero.is-primary .tabs li.is-active a{opacity:1}.hero.is-primary .tabs.is-boxed a,.hero.is-primary .tabs.is-toggle a{color:#fff}.hero.is-primary .tabs.is-boxed a:hover,.hero.is-primary .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-primary .tabs.is-boxed li.is-active a,.hero.is-primary .tabs.is-boxed li.is-active a:hover,.hero.is-primary .tabs.is-toggle li.is-active a,.hero.is-primary .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#00d1b2}.hero.is-primary.is-bold{background-image:linear-gradient(141deg,#009e6c 0,#00d1b2 71%,#00e7eb 100%)}@media screen and (max-width:768px){.hero.is-primary.is-bold .navbar-menu{background-image:linear-gradient(141deg,#009e6c 0,#00d1b2 71%,#00e7eb 100%)}}.hero.is-link{background-color:#3273dc;color:#fff}.hero.is-link a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-link strong{color:inherit}.hero.is-link .title{color:#fff}.hero.is-link .subtitle{color:rgba(255,255,255,.9)}.hero.is-link .subtitle a:not(.button),.hero.is-link .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-link .navbar-menu{background-color:#3273dc}}.hero.is-link .navbar-item,.hero.is-link .navbar-link{color:rgba(255,255,255,.7)}.hero.is-link .navbar-link.is-active,.hero.is-link .navbar-link:hover,.hero.is-link a.navbar-item.is-active,.hero.is-link a.navbar-item:hover{background-color:#2366d1;color:#fff}.hero.is-link .tabs a{color:#fff;opacity:.9}.hero.is-link .tabs a:hover{opacity:1}.hero.is-link .tabs li.is-active a{opacity:1}.hero.is-link .tabs.is-boxed a,.hero.is-link .tabs.is-toggle a{color:#fff}.hero.is-link .tabs.is-boxed a:hover,.hero.is-link .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-link .tabs.is-boxed li.is-active a,.hero.is-link .tabs.is-boxed li.is-active a:hover,.hero.is-link .tabs.is-toggle li.is-active a,.hero.is-link .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#3273dc}.hero.is-link.is-bold{background-image:linear-gradient(141deg,#1577c6 0,#3273dc 71%,#4366e5 100%)}@media screen and (max-width:768px){.hero.is-link.is-bold .navbar-menu{background-image:linear-gradient(141deg,#1577c6 0,#3273dc 71%,#4366e5 100%)}}.hero.is-info{background-color:#3298dc;color:#fff}.hero.is-info a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-info strong{color:inherit}.hero.is-info .title{color:#fff}.hero.is-info .subtitle{color:rgba(255,255,255,.9)}.hero.is-info .subtitle a:not(.button),.hero.is-info .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-info .navbar-menu{background-color:#3298dc}}.hero.is-info .navbar-item,.hero.is-info .navbar-link{color:rgba(255,255,255,.7)}.hero.is-info .navbar-link.is-active,.hero.is-info .navbar-link:hover,.hero.is-info a.navbar-item.is-active,.hero.is-info a.navbar-item:hover{background-color:#238cd1;color:#fff}.hero.is-info .tabs a{color:#fff;opacity:.9}.hero.is-info .tabs a:hover{opacity:1}.hero.is-info .tabs li.is-active a{opacity:1}.hero.is-info .tabs.is-boxed a,.hero.is-info .tabs.is-toggle a{color:#fff}.hero.is-info .tabs.is-boxed a:hover,.hero.is-info .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-info .tabs.is-boxed li.is-active a,.hero.is-info .tabs.is-boxed li.is-active a:hover,.hero.is-info .tabs.is-toggle li.is-active a,.hero.is-info .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#3298dc}.hero.is-info.is-bold{background-image:linear-gradient(141deg,#159dc6 0,#3298dc 71%,#4389e5 100%)}@media screen and (max-width:768px){.hero.is-info.is-bold .navbar-menu{background-image:linear-gradient(141deg,#159dc6 0,#3298dc 71%,#4389e5 100%)}}.hero.is-success{background-color:#48c774;color:#fff}.hero.is-success a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-success strong{color:inherit}.hero.is-success .title{color:#fff}.hero.is-success .subtitle{color:rgba(255,255,255,.9)}.hero.is-success .subtitle a:not(.button),.hero.is-success .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-success .navbar-menu{background-color:#48c774}}.hero.is-success .navbar-item,.hero.is-success .navbar-link{color:rgba(255,255,255,.7)}.hero.is-success .navbar-link.is-active,.hero.is-success .navbar-link:hover,.hero.is-success a.navbar-item.is-active,.hero.is-success a.navbar-item:hover{background-color:#3abb67;color:#fff}.hero.is-success .tabs a{color:#fff;opacity:.9}.hero.is-success .tabs a:hover{opacity:1}.hero.is-success .tabs li.is-active a{opacity:1}.hero.is-success .tabs.is-boxed a,.hero.is-success .tabs.is-toggle a{color:#fff}.hero.is-success .tabs.is-boxed a:hover,.hero.is-success .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-success .tabs.is-boxed li.is-active a,.hero.is-success .tabs.is-boxed li.is-active a:hover,.hero.is-success .tabs.is-toggle li.is-active a,.hero.is-success .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#48c774}.hero.is-success.is-bold{background-image:linear-gradient(141deg,#29b342 0,#48c774 71%,#56d296 100%)}@media screen and (max-width:768px){.hero.is-success.is-bold .navbar-menu{background-image:linear-gradient(141deg,#29b342 0,#48c774 71%,#56d296 100%)}}.hero.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.hero.is-warning a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-warning strong{color:inherit}.hero.is-warning .title{color:rgba(0,0,0,.7)}.hero.is-warning .subtitle{color:rgba(0,0,0,.9)}.hero.is-warning .subtitle a:not(.button),.hero.is-warning .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width:1023px){.hero.is-warning .navbar-menu{background-color:#ffdd57}}.hero.is-warning .navbar-item,.hero.is-warning .navbar-link{color:rgba(0,0,0,.7)}.hero.is-warning .navbar-link.is-active,.hero.is-warning .navbar-link:hover,.hero.is-warning a.navbar-item.is-active,.hero.is-warning a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.hero.is-warning .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-warning .tabs a:hover{opacity:1}.hero.is-warning .tabs li.is-active a{opacity:1}.hero.is-warning .tabs.is-boxed a,.hero.is-warning .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-warning .tabs.is-boxed a:hover,.hero.is-warning .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-warning .tabs.is-boxed li.is-active a,.hero.is-warning .tabs.is-boxed li.is-active a:hover,.hero.is-warning .tabs.is-toggle li.is-active a,.hero.is-warning .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:#ffdd57}.hero.is-warning.is-bold{background-image:linear-gradient(141deg,#ffaf24 0,#ffdd57 71%,#fffa70 100%)}@media screen and (max-width:768px){.hero.is-warning.is-bold .navbar-menu{background-image:linear-gradient(141deg,#ffaf24 0,#ffdd57 71%,#fffa70 100%)}}.hero.is-danger{background-color:#f14668;color:#fff}.hero.is-danger a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-danger strong{color:inherit}.hero.is-danger .title{color:#fff}.hero.is-danger .subtitle{color:rgba(255,255,255,.9)}.hero.is-danger .subtitle a:not(.button),.hero.is-danger .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-danger .navbar-menu{background-color:#f14668}}.hero.is-danger .navbar-item,.hero.is-danger .navbar-link{color:rgba(255,255,255,.7)}.hero.is-danger .navbar-link.is-active,.hero.is-danger .navbar-link:hover,.hero.is-danger a.navbar-item.is-active,.hero.is-danger a.navbar-item:hover{background-color:#ef2e55;color:#fff}.hero.is-danger .tabs a{color:#fff;opacity:.9}.hero.is-danger .tabs a:hover{opacity:1}.hero.is-danger .tabs li.is-active a{opacity:1}.hero.is-danger .tabs.is-boxed a,.hero.is-danger .tabs.is-toggle a{color:#fff}.hero.is-danger .tabs.is-boxed a:hover,.hero.is-danger .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-danger .tabs.is-boxed li.is-active a,.hero.is-danger .tabs.is-boxed li.is-active a:hover,.hero.is-danger .tabs.is-toggle li.is-active a,.hero.is-danger .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#f14668}.hero.is-danger.is-bold{background-image:linear-gradient(141deg,#fa0a62 0,#f14668 71%,#f7595f 100%)}@media screen and (max-width:768px){.hero.is-danger.is-bold .navbar-menu{background-image:linear-gradient(141deg,#fa0a62 0,#f14668 71%,#f7595f 100%)}}.hero.is-small .hero-body{padding:1.5rem}@media screen and (min-width:769px),print{.hero.is-medium .hero-body{padding:9rem 1.5rem}}@media screen and (min-width:769px),print{.hero.is-large .hero-body{padding:18rem 1.5rem}}.hero.is-fullheight .hero-body,.hero.is-fullheight-with-navbar .hero-body,.hero.is-halfheight .hero-body{align-items:center;display:flex}.hero.is-fullheight .hero-body>.container,.hero.is-fullheight-with-navbar .hero-body>.container,.hero.is-halfheight .hero-body>.container{flex-grow:1;flex-shrink:1}.hero.is-halfheight{min-height:50vh}.hero.is-fullheight{min-height:100vh}.hero-video{overflow:hidden}.hero-video video{left:50%;min-height:100%;min-width:100%;position:absolute;top:50%;transform:translate3d(-50%,-50%,0)}.hero-video.is-transparent{opacity:.3}@media screen and (max-width:768px){.hero-video{display:none}}.hero-buttons{margin-top:1.5rem}@media screen and (max-width:768px){.hero-buttons .button{display:flex}.hero-buttons .button:not(:last-child){margin-bottom:.75rem}}@media screen and (min-width:769px),print{.hero-buttons{display:flex;justify-content:center}.hero-buttons .button:not(:last-child){margin-right:1.5rem}}.hero-foot,.hero-head{flex-grow:0;flex-shrink:0}.hero-body{flex-grow:1;flex-shrink:0;padding:3rem 1.5rem}.section{padding:3rem 1.5rem}@media screen and (min-width:1024px){.section.is-medium{padding:9rem 1.5rem}.section.is-large{padding:18rem 1.5rem}}.footer{background-color:#fafafa;padding:3rem 1.5rem 6rem}
\ No newline at end of file
diff --git a/bookwyrm/static/css/fonts/.editorconfig b/bookwyrm/static/css/fonts/.editorconfig
new file mode 100644
index 00000000..2e5ec87a
--- /dev/null
+++ b/bookwyrm/static/css/fonts/.editorconfig
@@ -0,0 +1,4 @@
+# @see https://editorconfig.org/
+
+[*.svg]
+insert_final_newline = unset
diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css
deleted file mode 100644
index 50ce101e..00000000
--- a/bookwyrm/static/css/format.css
+++ /dev/null
@@ -1,161 +0,0 @@
-/* --- --- */
-.image {
- overflow: hidden;
-}
-.navbar .logo {
- max-height: 50px;
-}
-
-.card {
- overflow: visible;
-}
-.card-header-title {
- overflow: hidden;
-}
-
-/* --- SHELVING --- */
-.shelf-option:disabled > *::after {
- font-family: "icomoon";
- content: "\e918";
- margin-left: 0.5em;
-}
-
-/* --- TOGGLES --- */
-.toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover {
- background-color: hsl(171, 100%, 41%);
- color: white;
-}
-.hide-active[aria-pressed=true], .hide-inactive[aria-pressed=false] {
- display: none;
-}
-
-.hidden {
- display: none !important;
-}
-
-/* --- STARS --- */
-.rate-stars button.icon {
- background: none;
- border: none;
- padding: 0;
- margin: 0;
- display: inline;
-}
-.rate-stars:hover .icon:before {
- content: '\e9d9';
-}
-.rate-stars form:hover ~ form .icon:before{
- content: '\e9d7';
-}
-
-/* stars in a review form */
-.form-rate-stars:hover .icon:before {
- content: '\e9d9';
-}
-.form-rate-stars input + .icon:before {
- content: '\e9d9';
-}
-.form-rate-stars input:checked + .icon:before {
- content: '\e9d9';
-}
-.form-rate-stars input:checked + * ~ .icon:before {
- content: '\e9d7';
-}
-.form-rate-stars:hover label.icon:before {
- content: '\e9d9';
-}
-.form-rate-stars label.icon:hover:before {
- content: '\e9d9';
- }
-.form-rate-stars label.icon:hover ~ label.icon:before{
- content: '\e9d7';
-}
-
-
-/* --- BOOK COVERS --- */
-.cover-container {
- height: 250px;
- width: max-content;
- max-width: 250px;
-}
-.cover-container.is-large {
- height: max-content;
- max-width: 330px;
-}
-.cover-container.is-large img {
- max-height: 500px;
- height: auto;
-}
-.cover-container.is-medium {
- height: 150px;
-}
-.cover-container.is-small {
- height: 100px;
-}
-@media only screen and (max-width: 768px) {
- .cover-container {
- height: 200px;
- width: max-content;
- }
- .cover-container.is-medium {
- height: 100px;
- }
- .cover-container.is-small {
- height: 70px;
- }
-}
-
-.cover-container.is-medium .no-cover div {
- font-size: 0.9em;
- padding: 0.3em;
-}
-.cover-container.is-small .no-cover div {
- font-size: 0.7em;
- padding: 0.1em;
-}
-.book-cover {
- height: 100%;
- object-fit: scale-down;
-}
-.no-cover {
- position: relative;
- white-space: normal;
-}
-.no-cover div {
- position: absolute;
- padding: 1em;
- color: white;
- top: 0;
- left: 0;
- text-align: center;
-}
-
-
-/* --- AVATAR --- */
-.avatar {
- vertical-align: middle;
- display: inline;
-}
-.navbar .avatar {
- max-height: none;
-}
-
-
-/* --- QUOTES --- */
-.quote blockquote {
- position: relative;
- padding-left: 2em;
-}
-.quote blockquote:before, .quote blockquote:after {
- font-family: 'icomoon';
- position: absolute;
-}
-.quote blockquote:before {
- content: "\e906";
- top: 0;
- left: 0;
-}
-.quote blockquote:after {
- content: "\e905";
- right: 0;
-}
diff --git a/bookwyrm/static/css/icons.css b/bookwyrm/static/css/icons.css
deleted file mode 100644
index c84446af..00000000
--- a/bookwyrm/static/css/icons.css
+++ /dev/null
@@ -1,153 +0,0 @@
-@font-face {
- font-family: 'icomoon';
- src: url('fonts/icomoon.eot?n5x55');
- src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
- url('fonts/icomoon.ttf?n5x55') format('truetype'),
- url('fonts/icomoon.woff?n5x55') format('woff'),
- url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
- font-weight: normal;
- font-style: normal;
- font-display: block;
-}
-
-[class^="icon-"], [class*=" icon-"] {
- /* use !important to prevent issues with browser extensions that change fonts */
- font-family: 'icomoon' !important;
- speak: never;
- font-style: normal;
- font-weight: normal;
- font-variant: normal;
- text-transform: none;
- line-height: 1;
-
- /* Better Font Rendering =========== */
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-.icon-graphic-heart:before {
- content: "\e91e";
-}
-.icon-graphic-paperplane:before {
- content: "\e91f";
-}
-.icon-graphic-banknote:before {
- content: "\e920";
-}
-.icon-stars:before {
- content: "\e91a";
-}
-.icon-warning:before {
- content: "\e91b";
-}
-.icon-book:before {
- content: "\e900";
-}
-.icon-bookmark:before {
- content: "\e91c";
-}
-.icon-rss:before {
- content: "\e91d";
-}
-.icon-envelope:before {
- content: "\e901";
-}
-.icon-arrow-right:before {
- content: "\e902";
-}
-.icon-bell:before {
- content: "\e903";
-}
-.icon-x:before {
- content: "\e904";
-}
-.icon-quote-close:before {
- content: "\e905";
-}
-.icon-quote-open:before {
- content: "\e906";
-}
-.icon-image:before {
- content: "\e907";
-}
-.icon-pencil:before {
- content: "\e908";
-}
-.icon-list:before {
- content: "\e909";
-}
-.icon-unlock:before {
- content: "\e90a";
-}
-.icon-unlisted:before {
- content: "\e90a";
-}
-.icon-globe:before {
- content: "\e90b";
-}
-.icon-public:before {
- content: "\e90b";
-}
-.icon-lock:before {
- content: "\e90c";
-}
-.icon-followers:before {
- content: "\e90c";
-}
-.icon-chain-broken:before {
- content: "\e90d";
-}
-.icon-chain:before {
- content: "\e90e";
-}
-.icon-comments:before {
- content: "\e90f";
-}
-.icon-comment:before {
- content: "\e910";
-}
-.icon-boost:before {
- content: "\e911";
-}
-.icon-arrow-left:before {
- content: "\e912";
-}
-.icon-arrow-up:before {
- content: "\e913";
-}
-.icon-arrow-down:before {
- content: "\e914";
-}
-.icon-home:before {
- content: "\e915";
-}
-.icon-local:before {
- content: "\e916";
-}
-.icon-dots-three:before {
- content: "\e917";
-}
-.icon-check:before {
- content: "\e918";
-}
-.icon-dots-three-vertical:before {
- content: "\e919";
-}
-.icon-search:before {
- content: "\e986";
-}
-.icon-star-empty:before {
- content: "\e9d7";
-}
-.icon-star-half:before {
- content: "\e9d8";
-}
-.icon-star-full:before {
- content: "\e9d9";
-}
-.icon-heart:before {
- content: "\e9da";
-}
-.icon-plus:before {
- content: "\ea0a";
-}
diff --git a/bookwyrm/static/css/bulma.css.map b/bookwyrm/static/css/vendor/bulma.css.map
similarity index 100%
rename from bookwyrm/static/css/bulma.css.map
rename to bookwyrm/static/css/vendor/bulma.css.map
diff --git a/bookwyrm/static/css/vendor/bulma.min.css b/bookwyrm/static/css/vendor/bulma.min.css
new file mode 100644
index 00000000..ed54b7b3
--- /dev/null
+++ b/bookwyrm/static/css/vendor/bulma.min.css
@@ -0,0 +1 @@
+/*! bulma.io v0.9.2 | MIT License | github.com/jgthms/bulma */.button,.file-cta,.file-name,.input,.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous,.select select,.textarea{-moz-appearance:none;-webkit-appearance:none;align-items:center;border:1px solid transparent;border-radius:4px;box-shadow:none;display:inline-flex;font-size:1rem;height:2.5em;justify-content:flex-start;line-height:1.5;padding-bottom:calc(.5em - 1px);padding-left:calc(.75em - 1px);padding-right:calc(.75em - 1px);padding-top:calc(.5em - 1px);position:relative;vertical-align:top}.button:active,.button:focus,.file-cta:active,.file-cta:focus,.file-name:active,.file-name:focus,.input:active,.input:focus,.is-active.button,.is-active.file-cta,.is-active.file-name,.is-active.input,.is-active.pagination-ellipsis,.is-active.pagination-link,.is-active.pagination-next,.is-active.pagination-previous,.is-active.textarea,.is-focused.button,.is-focused.file-cta,.is-focused.file-name,.is-focused.input,.is-focused.pagination-ellipsis,.is-focused.pagination-link,.is-focused.pagination-next,.is-focused.pagination-previous,.is-focused.textarea,.pagination-ellipsis:active,.pagination-ellipsis:focus,.pagination-link:active,.pagination-link:focus,.pagination-next:active,.pagination-next:focus,.pagination-previous:active,.pagination-previous:focus,.select select.is-active,.select select.is-focused,.select select:active,.select select:focus,.textarea:active,.textarea:focus{outline:0}.button[disabled],.file-cta[disabled],.file-name[disabled],.input[disabled],.pagination-ellipsis[disabled],.pagination-link[disabled],.pagination-next[disabled],.pagination-previous[disabled],.select fieldset[disabled] select,.select select[disabled],.textarea[disabled],fieldset[disabled] .button,fieldset[disabled] .file-cta,fieldset[disabled] .file-name,fieldset[disabled] .input,fieldset[disabled] .pagination-ellipsis,fieldset[disabled] .pagination-link,fieldset[disabled] .pagination-next,fieldset[disabled] .pagination-previous,fieldset[disabled] .select select,fieldset[disabled] .textarea{cursor:not-allowed}.breadcrumb,.button,.file,.is-unselectable,.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous,.tabs{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.navbar-link:not(.is-arrowless)::after,.select:not(.is-multiple):not(.is-loading)::after{border:3px solid transparent;border-radius:2px;border-right:0;border-top:0;content:" ";display:block;height:.625em;margin-top:-.4375em;pointer-events:none;position:absolute;top:50%;transform:rotate(-45deg);transform-origin:center;width:.625em}.block:not(:last-child),.box:not(:last-child),.breadcrumb:not(:last-child),.content:not(:last-child),.highlight:not(:last-child),.level:not(:last-child),.message:not(:last-child),.notification:not(:last-child),.pagination:not(:last-child),.progress:not(:last-child),.subtitle:not(:last-child),.table-container:not(:last-child),.table:not(:last-child),.tabs:not(:last-child),.title:not(:last-child){margin-bottom:1.5rem}.delete,.modal-close{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-moz-appearance:none;-webkit-appearance:none;background-color:rgba(10,10,10,.2);border:none;border-radius:290486px;cursor:pointer;pointer-events:auto;display:inline-block;flex-grow:0;flex-shrink:0;font-size:0;height:20px;max-height:20px;max-width:20px;min-height:20px;min-width:20px;outline:0;position:relative;vertical-align:top;width:20px}.delete::after,.delete::before,.modal-close::after,.modal-close::before{background-color:#fff;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.delete::before,.modal-close::before{height:2px;width:50%}.delete::after,.modal-close::after{height:50%;width:2px}.delete:focus,.delete:hover,.modal-close:focus,.modal-close:hover{background-color:rgba(10,10,10,.3)}.delete:active,.modal-close:active{background-color:rgba(10,10,10,.4)}.is-small.delete,.is-small.modal-close{height:16px;max-height:16px;max-width:16px;min-height:16px;min-width:16px;width:16px}.is-medium.delete,.is-medium.modal-close{height:24px;max-height:24px;max-width:24px;min-height:24px;min-width:24px;width:24px}.is-large.delete,.is-large.modal-close{height:32px;max-height:32px;max-width:32px;min-height:32px;min-width:32px;width:32px}.button.is-loading::after,.control.is-loading::after,.loader,.select.is-loading::after{-webkit-animation:spinAround .5s infinite linear;animation:spinAround .5s infinite linear;border:2px solid #dbdbdb;border-radius:290486px;border-right-color:transparent;border-top-color:transparent;content:"";display:block;height:1em;position:relative;width:1em}.hero-video,.image.is-16by9 .has-ratio,.image.is-16by9 img,.image.is-1by1 .has-ratio,.image.is-1by1 img,.image.is-1by2 .has-ratio,.image.is-1by2 img,.image.is-1by3 .has-ratio,.image.is-1by3 img,.image.is-2by1 .has-ratio,.image.is-2by1 img,.image.is-2by3 .has-ratio,.image.is-2by3 img,.image.is-3by1 .has-ratio,.image.is-3by1 img,.image.is-3by2 .has-ratio,.image.is-3by2 img,.image.is-3by4 .has-ratio,.image.is-3by4 img,.image.is-3by5 .has-ratio,.image.is-3by5 img,.image.is-4by3 .has-ratio,.image.is-4by3 img,.image.is-4by5 .has-ratio,.image.is-4by5 img,.image.is-5by3 .has-ratio,.image.is-5by3 img,.image.is-5by4 .has-ratio,.image.is-5by4 img,.image.is-9by16 .has-ratio,.image.is-9by16 img,.image.is-square .has-ratio,.image.is-square img,.is-overlay,.modal,.modal-background{bottom:0;left:0;position:absolute;right:0;top:0}/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */blockquote,body,dd,dl,dt,fieldset,figure,h1,h2,h3,h4,h5,h6,hr,html,iframe,legend,li,ol,p,pre,textarea,ul{margin:0;padding:0}h1,h2,h3,h4,h5,h6{font-size:100%;font-weight:400}ul{list-style:none}button,input,select,textarea{margin:0}html{box-sizing:border-box}*,::after,::before{box-sizing:inherit}img,video{height:auto;max-width:100%}iframe{border:0}table{border-collapse:collapse;border-spacing:0}td,th{padding:0}td:not([align]),th:not([align]){text-align:inherit}html{background-color:#fff;font-size:16px;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;min-width:300px;overflow-x:hidden;overflow-y:scroll;text-rendering:optimizeLegibility;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;-ms-text-size-adjust:100%;text-size-adjust:100%}article,aside,figure,footer,header,hgroup,section{display:block}body,button,input,optgroup,select,textarea{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif}code,pre{-moz-osx-font-smoothing:auto;-webkit-font-smoothing:auto;font-family:monospace}body{color:#4a4a4a;font-size:1em;font-weight:400;line-height:1.5}a{color:#3273dc;cursor:pointer;text-decoration:none}a strong{color:currentColor}a:hover{color:#363636}code{background-color:#f5f5f5;color:#da1039;font-size:.875em;font-weight:400;padding:.25em .5em .25em}hr{background-color:#f5f5f5;border:none;display:block;height:2px;margin:1.5rem 0}img{height:auto;max-width:100%}input[type=checkbox],input[type=radio]{vertical-align:baseline}small{font-size:.875em}span{font-style:inherit;font-weight:inherit}strong{color:#363636;font-weight:700}fieldset{border:none}pre{-webkit-overflow-scrolling:touch;background-color:#f5f5f5;color:#4a4a4a;font-size:.875em;overflow-x:auto;padding:1.25rem 1.5rem;white-space:pre;word-wrap:normal}pre code{background-color:transparent;color:currentColor;font-size:1em;padding:0}table td,table th{vertical-align:top}table td:not([align]),table th:not([align]){text-align:inherit}table th{color:#363636}@-webkit-keyframes spinAround{from{transform:rotate(0)}to{transform:rotate(359deg)}}@keyframes spinAround{from{transform:rotate(0)}to{transform:rotate(359deg)}}.box{background-color:#fff;border-radius:6px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);color:#4a4a4a;display:block;padding:1.25rem}a.box:focus,a.box:hover{box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px #3273dc}a.box:active{box-shadow:inset 0 1px 2px rgba(10,10,10,.2),0 0 0 1px #3273dc}.button{background-color:#fff;border-color:#dbdbdb;border-width:1px;color:#363636;cursor:pointer;justify-content:center;padding-bottom:calc(.5em - 1px);padding-left:1em;padding-right:1em;padding-top:calc(.5em - 1px);text-align:center;white-space:nowrap}.button strong{color:inherit}.button .icon,.button .icon.is-large,.button .icon.is-medium,.button .icon.is-small{height:1.5em;width:1.5em}.button .icon:first-child:not(:last-child){margin-left:calc(-.5em - 1px);margin-right:.25em}.button .icon:last-child:not(:first-child){margin-left:.25em;margin-right:calc(-.5em - 1px)}.button .icon:first-child:last-child{margin-left:calc(-.5em - 1px);margin-right:calc(-.5em - 1px)}.button.is-hovered,.button:hover{border-color:#b5b5b5;color:#363636}.button.is-focused,.button:focus{border-color:#3273dc;color:#363636}.button.is-focused:not(:active),.button:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.button.is-active,.button:active{border-color:#4a4a4a;color:#363636}.button.is-text{background-color:transparent;border-color:transparent;color:#4a4a4a;text-decoration:underline}.button.is-text.is-focused,.button.is-text.is-hovered,.button.is-text:focus,.button.is-text:hover{background-color:#f5f5f5;color:#363636}.button.is-text.is-active,.button.is-text:active{background-color:#e8e8e8;color:#363636}.button.is-text[disabled],fieldset[disabled] .button.is-text{background-color:transparent;border-color:transparent;box-shadow:none}.button.is-ghost{background:0 0;border-color:transparent;color:#3273dc;text-decoration:none}.button.is-ghost.is-hovered,.button.is-ghost:hover{color:#3273dc;text-decoration:underline}.button.is-white{background-color:#fff;border-color:transparent;color:#0a0a0a}.button.is-white.is-hovered,.button.is-white:hover{background-color:#f9f9f9;border-color:transparent;color:#0a0a0a}.button.is-white.is-focused,.button.is-white:focus{border-color:transparent;color:#0a0a0a}.button.is-white.is-focused:not(:active),.button.is-white:focus:not(:active){box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.button.is-white.is-active,.button.is-white:active{background-color:#f2f2f2;border-color:transparent;color:#0a0a0a}.button.is-white[disabled],fieldset[disabled] .button.is-white{background-color:#fff;border-color:transparent;box-shadow:none}.button.is-white.is-inverted{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted.is-hovered,.button.is-white.is-inverted:hover{background-color:#000}.button.is-white.is-inverted[disabled],fieldset[disabled] .button.is-white.is-inverted{background-color:#0a0a0a;border-color:transparent;box-shadow:none;color:#fff}.button.is-white.is-loading::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-white.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-white.is-outlined.is-focused,.button.is-white.is-outlined.is-hovered,.button.is-white.is-outlined:focus,.button.is-white.is-outlined:hover{background-color:#fff;border-color:#fff;color:#0a0a0a}.button.is-white.is-outlined.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-white.is-outlined.is-loading.is-focused::after,.button.is-white.is-outlined.is-loading.is-hovered::after,.button.is-white.is-outlined.is-loading:focus::after,.button.is-white.is-outlined.is-loading:hover::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-white.is-outlined[disabled],fieldset[disabled] .button.is-white.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-white.is-inverted.is-outlined.is-focused,.button.is-white.is-inverted.is-outlined.is-hovered,.button.is-white.is-inverted.is-outlined:focus,.button.is-white.is-inverted.is-outlined:hover{background-color:#0a0a0a;color:#fff}.button.is-white.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-white.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-white.is-inverted.is-outlined.is-loading:focus::after,.button.is-white.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-white.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-white.is-inverted.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black{background-color:#0a0a0a;border-color:transparent;color:#fff}.button.is-black.is-hovered,.button.is-black:hover{background-color:#040404;border-color:transparent;color:#fff}.button.is-black.is-focused,.button.is-black:focus{border-color:transparent;color:#fff}.button.is-black.is-focused:not(:active),.button.is-black:focus:not(:active){box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.button.is-black.is-active,.button.is-black:active{background-color:#000;border-color:transparent;color:#fff}.button.is-black[disabled],fieldset[disabled] .button.is-black{background-color:#0a0a0a;border-color:transparent;box-shadow:none}.button.is-black.is-inverted{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted.is-hovered,.button.is-black.is-inverted:hover{background-color:#f2f2f2}.button.is-black.is-inverted[disabled],fieldset[disabled] .button.is-black.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#0a0a0a}.button.is-black.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;color:#0a0a0a}.button.is-black.is-outlined.is-focused,.button.is-black.is-outlined.is-hovered,.button.is-black.is-outlined:focus,.button.is-black.is-outlined:hover{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.button.is-black.is-outlined.is-loading::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-black.is-outlined.is-loading.is-focused::after,.button.is-black.is-outlined.is-loading.is-hovered::after,.button.is-black.is-outlined.is-loading:focus::after,.button.is-black.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-black.is-outlined[disabled],fieldset[disabled] .button.is-black.is-outlined{background-color:transparent;border-color:#0a0a0a;box-shadow:none;color:#0a0a0a}.button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-black.is-inverted.is-outlined.is-focused,.button.is-black.is-inverted.is-outlined.is-hovered,.button.is-black.is-inverted.is-outlined:focus,.button.is-black.is-inverted.is-outlined:hover{background-color:#fff;color:#0a0a0a}.button.is-black.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-black.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-black.is-inverted.is-outlined.is-loading:focus::after,.button.is-black.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #0a0a0a #0a0a0a!important}.button.is-black.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-black.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-light{background-color:#f5f5f5;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-hovered,.button.is-light:hover{background-color:#eee;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-focused,.button.is-light:focus{border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light.is-focused:not(:active),.button.is-light:focus:not(:active){box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.button.is-light.is-active,.button.is-light:active{background-color:#e8e8e8;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-light[disabled],fieldset[disabled] .button.is-light{background-color:#f5f5f5;border-color:transparent;box-shadow:none}.button.is-light.is-inverted{background-color:rgba(0,0,0,.7);color:#f5f5f5}.button.is-light.is-inverted.is-hovered,.button.is-light.is-inverted:hover{background-color:rgba(0,0,0,.7)}.button.is-light.is-inverted[disabled],fieldset[disabled] .button.is-light.is-inverted{background-color:rgba(0,0,0,.7);border-color:transparent;box-shadow:none;color:#f5f5f5}.button.is-light.is-loading::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;color:#f5f5f5}.button.is-light.is-outlined.is-focused,.button.is-light.is-outlined.is-hovered,.button.is-light.is-outlined:focus,.button.is-light.is-outlined:hover{background-color:#f5f5f5;border-color:#f5f5f5;color:rgba(0,0,0,.7)}.button.is-light.is-outlined.is-loading::after{border-color:transparent transparent #f5f5f5 #f5f5f5!important}.button.is-light.is-outlined.is-loading.is-focused::after,.button.is-light.is-outlined.is-loading.is-hovered::after,.button.is-light.is-outlined.is-loading:focus::after,.button.is-light.is-outlined.is-loading:hover::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-light.is-outlined[disabled],fieldset[disabled] .button.is-light.is-outlined{background-color:transparent;border-color:#f5f5f5;box-shadow:none;color:#f5f5f5}.button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-light.is-inverted.is-outlined.is-focused,.button.is-light.is-inverted.is-outlined.is-hovered,.button.is-light.is-inverted.is-outlined:focus,.button.is-light.is-inverted.is-outlined:hover{background-color:rgba(0,0,0,.7);color:#f5f5f5}.button.is-light.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-light.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-light.is-inverted.is-outlined.is-loading:focus::after,.button.is-light.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #f5f5f5 #f5f5f5!important}.button.is-light.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-light.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-dark{background-color:#363636;border-color:transparent;color:#fff}.button.is-dark.is-hovered,.button.is-dark:hover{background-color:#2f2f2f;border-color:transparent;color:#fff}.button.is-dark.is-focused,.button.is-dark:focus{border-color:transparent;color:#fff}.button.is-dark.is-focused:not(:active),.button.is-dark:focus:not(:active){box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.button.is-dark.is-active,.button.is-dark:active{background-color:#292929;border-color:transparent;color:#fff}.button.is-dark[disabled],fieldset[disabled] .button.is-dark{background-color:#363636;border-color:transparent;box-shadow:none}.button.is-dark.is-inverted{background-color:#fff;color:#363636}.button.is-dark.is-inverted.is-hovered,.button.is-dark.is-inverted:hover{background-color:#f2f2f2}.button.is-dark.is-inverted[disabled],fieldset[disabled] .button.is-dark.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#363636}.button.is-dark.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined{background-color:transparent;border-color:#363636;color:#363636}.button.is-dark.is-outlined.is-focused,.button.is-dark.is-outlined.is-hovered,.button.is-dark.is-outlined:focus,.button.is-dark.is-outlined:hover{background-color:#363636;border-color:#363636;color:#fff}.button.is-dark.is-outlined.is-loading::after{border-color:transparent transparent #363636 #363636!important}.button.is-dark.is-outlined.is-loading.is-focused::after,.button.is-dark.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-outlined.is-loading:focus::after,.button.is-dark.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-dark.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-outlined{background-color:transparent;border-color:#363636;box-shadow:none;color:#363636}.button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-dark.is-inverted.is-outlined.is-focused,.button.is-dark.is-inverted.is-outlined.is-hovered,.button.is-dark.is-inverted.is-outlined:focus,.button.is-dark.is-inverted.is-outlined:hover{background-color:#fff;color:#363636}.button.is-dark.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-dark.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-dark.is-inverted.is-outlined.is-loading:focus::after,.button.is-dark.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #363636 #363636!important}.button.is-dark.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-dark.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary{background-color:#00d1b2;border-color:transparent;color:#fff}.button.is-primary.is-hovered,.button.is-primary:hover{background-color:#00c4a7;border-color:transparent;color:#fff}.button.is-primary.is-focused,.button.is-primary:focus{border-color:transparent;color:#fff}.button.is-primary.is-focused:not(:active),.button.is-primary:focus:not(:active){box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.button.is-primary.is-active,.button.is-primary:active{background-color:#00b89c;border-color:transparent;color:#fff}.button.is-primary[disabled],fieldset[disabled] .button.is-primary{background-color:#00d1b2;border-color:transparent;box-shadow:none}.button.is-primary.is-inverted{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted.is-hovered,.button.is-primary.is-inverted:hover{background-color:#f2f2f2}.button.is-primary.is-inverted[disabled],fieldset[disabled] .button.is-primary.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#00d1b2}.button.is-primary.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;color:#00d1b2}.button.is-primary.is-outlined.is-focused,.button.is-primary.is-outlined.is-hovered,.button.is-primary.is-outlined:focus,.button.is-primary.is-outlined:hover{background-color:#00d1b2;border-color:#00d1b2;color:#fff}.button.is-primary.is-outlined.is-loading::after{border-color:transparent transparent #00d1b2 #00d1b2!important}.button.is-primary.is-outlined.is-loading.is-focused::after,.button.is-primary.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-outlined.is-loading:focus::after,.button.is-primary.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-primary.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-outlined{background-color:transparent;border-color:#00d1b2;box-shadow:none;color:#00d1b2}.button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-primary.is-inverted.is-outlined.is-focused,.button.is-primary.is-inverted.is-outlined.is-hovered,.button.is-primary.is-inverted.is-outlined:focus,.button.is-primary.is-inverted.is-outlined:hover{background-color:#fff;color:#00d1b2}.button.is-primary.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-primary.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-primary.is-inverted.is-outlined.is-loading:focus::after,.button.is-primary.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #00d1b2 #00d1b2!important}.button.is-primary.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-primary.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-primary.is-light{background-color:#ebfffc;color:#00947e}.button.is-primary.is-light.is-hovered,.button.is-primary.is-light:hover{background-color:#defffa;border-color:transparent;color:#00947e}.button.is-primary.is-light.is-active,.button.is-primary.is-light:active{background-color:#d1fff8;border-color:transparent;color:#00947e}.button.is-link{background-color:#3273dc;border-color:transparent;color:#fff}.button.is-link.is-hovered,.button.is-link:hover{background-color:#276cda;border-color:transparent;color:#fff}.button.is-link.is-focused,.button.is-link:focus{border-color:transparent;color:#fff}.button.is-link.is-focused:not(:active),.button.is-link:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.button.is-link.is-active,.button.is-link:active{background-color:#2366d1;border-color:transparent;color:#fff}.button.is-link[disabled],fieldset[disabled] .button.is-link{background-color:#3273dc;border-color:transparent;box-shadow:none}.button.is-link.is-inverted{background-color:#fff;color:#3273dc}.button.is-link.is-inverted.is-hovered,.button.is-link.is-inverted:hover{background-color:#f2f2f2}.button.is-link.is-inverted[disabled],fieldset[disabled] .button.is-link.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#3273dc}.button.is-link.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined{background-color:transparent;border-color:#3273dc;color:#3273dc}.button.is-link.is-outlined.is-focused,.button.is-link.is-outlined.is-hovered,.button.is-link.is-outlined:focus,.button.is-link.is-outlined:hover{background-color:#3273dc;border-color:#3273dc;color:#fff}.button.is-link.is-outlined.is-loading::after{border-color:transparent transparent #3273dc #3273dc!important}.button.is-link.is-outlined.is-loading.is-focused::after,.button.is-link.is-outlined.is-loading.is-hovered::after,.button.is-link.is-outlined.is-loading:focus::after,.button.is-link.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-link.is-outlined[disabled],fieldset[disabled] .button.is-link.is-outlined{background-color:transparent;border-color:#3273dc;box-shadow:none;color:#3273dc}.button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-link.is-inverted.is-outlined.is-focused,.button.is-link.is-inverted.is-outlined.is-hovered,.button.is-link.is-inverted.is-outlined:focus,.button.is-link.is-inverted.is-outlined:hover{background-color:#fff;color:#3273dc}.button.is-link.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-link.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-link.is-inverted.is-outlined.is-loading:focus::after,.button.is-link.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #3273dc #3273dc!important}.button.is-link.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-link.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-link.is-light{background-color:#eef3fc;color:#2160c4}.button.is-link.is-light.is-hovered,.button.is-link.is-light:hover{background-color:#e3ecfa;border-color:transparent;color:#2160c4}.button.is-link.is-light.is-active,.button.is-link.is-light:active{background-color:#d8e4f8;border-color:transparent;color:#2160c4}.button.is-info{background-color:#3298dc;border-color:transparent;color:#fff}.button.is-info.is-hovered,.button.is-info:hover{background-color:#2793da;border-color:transparent;color:#fff}.button.is-info.is-focused,.button.is-info:focus{border-color:transparent;color:#fff}.button.is-info.is-focused:not(:active),.button.is-info:focus:not(:active){box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.button.is-info.is-active,.button.is-info:active{background-color:#238cd1;border-color:transparent;color:#fff}.button.is-info[disabled],fieldset[disabled] .button.is-info{background-color:#3298dc;border-color:transparent;box-shadow:none}.button.is-info.is-inverted{background-color:#fff;color:#3298dc}.button.is-info.is-inverted.is-hovered,.button.is-info.is-inverted:hover{background-color:#f2f2f2}.button.is-info.is-inverted[disabled],fieldset[disabled] .button.is-info.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#3298dc}.button.is-info.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined{background-color:transparent;border-color:#3298dc;color:#3298dc}.button.is-info.is-outlined.is-focused,.button.is-info.is-outlined.is-hovered,.button.is-info.is-outlined:focus,.button.is-info.is-outlined:hover{background-color:#3298dc;border-color:#3298dc;color:#fff}.button.is-info.is-outlined.is-loading::after{border-color:transparent transparent #3298dc #3298dc!important}.button.is-info.is-outlined.is-loading.is-focused::after,.button.is-info.is-outlined.is-loading.is-hovered::after,.button.is-info.is-outlined.is-loading:focus::after,.button.is-info.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-info.is-outlined[disabled],fieldset[disabled] .button.is-info.is-outlined{background-color:transparent;border-color:#3298dc;box-shadow:none;color:#3298dc}.button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-info.is-inverted.is-outlined.is-focused,.button.is-info.is-inverted.is-outlined.is-hovered,.button.is-info.is-inverted.is-outlined:focus,.button.is-info.is-inverted.is-outlined:hover{background-color:#fff;color:#3298dc}.button.is-info.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-info.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-info.is-inverted.is-outlined.is-loading:focus::after,.button.is-info.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #3298dc #3298dc!important}.button.is-info.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-info.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-info.is-light{background-color:#eef6fc;color:#1d72aa}.button.is-info.is-light.is-hovered,.button.is-info.is-light:hover{background-color:#e3f1fa;border-color:transparent;color:#1d72aa}.button.is-info.is-light.is-active,.button.is-info.is-light:active{background-color:#d8ebf8;border-color:transparent;color:#1d72aa}.button.is-success{background-color:#48c774;border-color:transparent;color:#fff}.button.is-success.is-hovered,.button.is-success:hover{background-color:#3ec46d;border-color:transparent;color:#fff}.button.is-success.is-focused,.button.is-success:focus{border-color:transparent;color:#fff}.button.is-success.is-focused:not(:active),.button.is-success:focus:not(:active){box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.button.is-success.is-active,.button.is-success:active{background-color:#3abb67;border-color:transparent;color:#fff}.button.is-success[disabled],fieldset[disabled] .button.is-success{background-color:#48c774;border-color:transparent;box-shadow:none}.button.is-success.is-inverted{background-color:#fff;color:#48c774}.button.is-success.is-inverted.is-hovered,.button.is-success.is-inverted:hover{background-color:#f2f2f2}.button.is-success.is-inverted[disabled],fieldset[disabled] .button.is-success.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#48c774}.button.is-success.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined{background-color:transparent;border-color:#48c774;color:#48c774}.button.is-success.is-outlined.is-focused,.button.is-success.is-outlined.is-hovered,.button.is-success.is-outlined:focus,.button.is-success.is-outlined:hover{background-color:#48c774;border-color:#48c774;color:#fff}.button.is-success.is-outlined.is-loading::after{border-color:transparent transparent #48c774 #48c774!important}.button.is-success.is-outlined.is-loading.is-focused::after,.button.is-success.is-outlined.is-loading.is-hovered::after,.button.is-success.is-outlined.is-loading:focus::after,.button.is-success.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-success.is-outlined[disabled],fieldset[disabled] .button.is-success.is-outlined{background-color:transparent;border-color:#48c774;box-shadow:none;color:#48c774}.button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-success.is-inverted.is-outlined.is-focused,.button.is-success.is-inverted.is-outlined.is-hovered,.button.is-success.is-inverted.is-outlined:focus,.button.is-success.is-inverted.is-outlined:hover{background-color:#fff;color:#48c774}.button.is-success.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-success.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-success.is-inverted.is-outlined.is-loading:focus::after,.button.is-success.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #48c774 #48c774!important}.button.is-success.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-success.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-success.is-light{background-color:#effaf3;color:#257942}.button.is-success.is-light.is-hovered,.button.is-success.is-light:hover{background-color:#e6f7ec;border-color:transparent;color:#257942}.button.is-success.is-light.is-active,.button.is-success.is-light:active{background-color:#dcf4e4;border-color:transparent;color:#257942}.button.is-warning{background-color:#ffdd57;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-hovered,.button.is-warning:hover{background-color:#ffdb4a;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-focused,.button.is-warning:focus{border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning.is-focused:not(:active),.button.is-warning:focus:not(:active){box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.button.is-warning.is-active,.button.is-warning:active{background-color:#ffd83d;border-color:transparent;color:rgba(0,0,0,.7)}.button.is-warning[disabled],fieldset[disabled] .button.is-warning{background-color:#ffdd57;border-color:transparent;box-shadow:none}.button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);color:#ffdd57}.button.is-warning.is-inverted.is-hovered,.button.is-warning.is-inverted:hover{background-color:rgba(0,0,0,.7)}.button.is-warning.is-inverted[disabled],fieldset[disabled] .button.is-warning.is-inverted{background-color:rgba(0,0,0,.7);border-color:transparent;box-shadow:none;color:#ffdd57}.button.is-warning.is-loading::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined{background-color:transparent;border-color:#ffdd57;color:#ffdd57}.button.is-warning.is-outlined.is-focused,.button.is-warning.is-outlined.is-hovered,.button.is-warning.is-outlined:focus,.button.is-warning.is-outlined:hover{background-color:#ffdd57;border-color:#ffdd57;color:rgba(0,0,0,.7)}.button.is-warning.is-outlined.is-loading::after{border-color:transparent transparent #ffdd57 #ffdd57!important}.button.is-warning.is-outlined.is-loading.is-focused::after,.button.is-warning.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-outlined.is-loading:focus::after,.button.is-warning.is-outlined.is-loading:hover::after{border-color:transparent transparent rgba(0,0,0,.7) rgba(0,0,0,.7)!important}.button.is-warning.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-outlined{background-color:transparent;border-color:#ffdd57;box-shadow:none;color:#ffdd57}.button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);color:rgba(0,0,0,.7)}.button.is-warning.is-inverted.is-outlined.is-focused,.button.is-warning.is-inverted.is-outlined.is-hovered,.button.is-warning.is-inverted.is-outlined:focus,.button.is-warning.is-inverted.is-outlined:hover{background-color:rgba(0,0,0,.7);color:#ffdd57}.button.is-warning.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-warning.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-warning.is-inverted.is-outlined.is-loading:focus::after,.button.is-warning.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #ffdd57 #ffdd57!important}.button.is-warning.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-warning.is-inverted.is-outlined{background-color:transparent;border-color:rgba(0,0,0,.7);box-shadow:none;color:rgba(0,0,0,.7)}.button.is-warning.is-light{background-color:#fffbeb;color:#947600}.button.is-warning.is-light.is-hovered,.button.is-warning.is-light:hover{background-color:#fff8de;border-color:transparent;color:#947600}.button.is-warning.is-light.is-active,.button.is-warning.is-light:active{background-color:#fff6d1;border-color:transparent;color:#947600}.button.is-danger{background-color:#f14668;border-color:transparent;color:#fff}.button.is-danger.is-hovered,.button.is-danger:hover{background-color:#f03a5f;border-color:transparent;color:#fff}.button.is-danger.is-focused,.button.is-danger:focus{border-color:transparent;color:#fff}.button.is-danger.is-focused:not(:active),.button.is-danger:focus:not(:active){box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.button.is-danger.is-active,.button.is-danger:active{background-color:#ef2e55;border-color:transparent;color:#fff}.button.is-danger[disabled],fieldset[disabled] .button.is-danger{background-color:#f14668;border-color:transparent;box-shadow:none}.button.is-danger.is-inverted{background-color:#fff;color:#f14668}.button.is-danger.is-inverted.is-hovered,.button.is-danger.is-inverted:hover{background-color:#f2f2f2}.button.is-danger.is-inverted[disabled],fieldset[disabled] .button.is-danger.is-inverted{background-color:#fff;border-color:transparent;box-shadow:none;color:#f14668}.button.is-danger.is-loading::after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;color:#f14668}.button.is-danger.is-outlined.is-focused,.button.is-danger.is-outlined.is-hovered,.button.is-danger.is-outlined:focus,.button.is-danger.is-outlined:hover{background-color:#f14668;border-color:#f14668;color:#fff}.button.is-danger.is-outlined.is-loading::after{border-color:transparent transparent #f14668 #f14668!important}.button.is-danger.is-outlined.is-loading.is-focused::after,.button.is-danger.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-outlined.is-loading:focus::after,.button.is-danger.is-outlined.is-loading:hover::after{border-color:transparent transparent #fff #fff!important}.button.is-danger.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-outlined{background-color:transparent;border-color:#f14668;box-shadow:none;color:#f14668}.button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;color:#fff}.button.is-danger.is-inverted.is-outlined.is-focused,.button.is-danger.is-inverted.is-outlined.is-hovered,.button.is-danger.is-inverted.is-outlined:focus,.button.is-danger.is-inverted.is-outlined:hover{background-color:#fff;color:#f14668}.button.is-danger.is-inverted.is-outlined.is-loading.is-focused::after,.button.is-danger.is-inverted.is-outlined.is-loading.is-hovered::after,.button.is-danger.is-inverted.is-outlined.is-loading:focus::after,.button.is-danger.is-inverted.is-outlined.is-loading:hover::after{border-color:transparent transparent #f14668 #f14668!important}.button.is-danger.is-inverted.is-outlined[disabled],fieldset[disabled] .button.is-danger.is-inverted.is-outlined{background-color:transparent;border-color:#fff;box-shadow:none;color:#fff}.button.is-danger.is-light{background-color:#feecf0;color:#cc0f35}.button.is-danger.is-light.is-hovered,.button.is-danger.is-light:hover{background-color:#fde0e6;border-color:transparent;color:#cc0f35}.button.is-danger.is-light.is-active,.button.is-danger.is-light:active{background-color:#fcd4dc;border-color:transparent;color:#cc0f35}.button.is-small{font-size:.75rem}.button.is-small:not(.is-rounded){border-radius:2px}.button.is-normal{font-size:1rem}.button.is-medium{font-size:1.25rem}.button.is-large{font-size:1.5rem}.button[disabled],fieldset[disabled] .button{background-color:#fff;border-color:#dbdbdb;box-shadow:none;opacity:.5}.button.is-fullwidth{display:flex;width:100%}.button.is-loading{color:transparent!important;pointer-events:none}.button.is-loading::after{position:absolute;left:calc(50% - (1em / 2));top:calc(50% - (1em / 2));position:absolute!important}.button.is-static{background-color:#f5f5f5;border-color:#dbdbdb;color:#7a7a7a;box-shadow:none;pointer-events:none}.button.is-rounded{border-radius:290486px;padding-left:calc(1em + .25em);padding-right:calc(1em + .25em)}.buttons{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.buttons .button{margin-bottom:.5rem}.buttons .button:not(:last-child):not(.is-fullwidth){margin-right:.5rem}.buttons:last-child{margin-bottom:-.5rem}.buttons:not(:last-child){margin-bottom:1rem}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large){font-size:.75rem}.buttons.are-small .button:not(.is-normal):not(.is-medium):not(.is-large):not(.is-rounded){border-radius:2px}.buttons.are-medium .button:not(.is-small):not(.is-normal):not(.is-large){font-size:1.25rem}.buttons.are-large .button:not(.is-small):not(.is-normal):not(.is-medium){font-size:1.5rem}.buttons.has-addons .button:not(:first-child){border-bottom-left-radius:0;border-top-left-radius:0}.buttons.has-addons .button:not(:last-child){border-bottom-right-radius:0;border-top-right-radius:0;margin-right:-1px}.buttons.has-addons .button:last-child{margin-right:0}.buttons.has-addons .button.is-hovered,.buttons.has-addons .button:hover{z-index:2}.buttons.has-addons .button.is-active,.buttons.has-addons .button.is-focused,.buttons.has-addons .button.is-selected,.buttons.has-addons .button:active,.buttons.has-addons .button:focus{z-index:3}.buttons.has-addons .button.is-active:hover,.buttons.has-addons .button.is-focused:hover,.buttons.has-addons .button.is-selected:hover,.buttons.has-addons .button:active:hover,.buttons.has-addons .button:focus:hover{z-index:4}.buttons.has-addons .button.is-expanded{flex-grow:1;flex-shrink:1}.buttons.is-centered{justify-content:center}.buttons.is-centered:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.buttons.is-right{justify-content:flex-end}.buttons.is-right:not(.has-addons) .button:not(.is-fullwidth){margin-left:.25rem;margin-right:.25rem}.container{flex-grow:1;margin:0 auto;position:relative;width:auto}.container.is-fluid{max-width:none!important;padding-left:32px;padding-right:32px;width:100%}@media screen and (min-width:1024px){.container{max-width:960px}}@media screen and (max-width:1215px){.container.is-widescreen:not(.is-max-desktop){max-width:1152px}}@media screen and (max-width:1407px){.container.is-fullhd:not(.is-max-desktop):not(.is-max-widescreen){max-width:1344px}}@media screen and (min-width:1216px){.container:not(.is-max-desktop){max-width:1152px}}@media screen and (min-width:1408px){.container:not(.is-max-desktop):not(.is-max-widescreen){max-width:1344px}}.content li+li{margin-top:.25em}.content blockquote:not(:last-child),.content dl:not(:last-child),.content ol:not(:last-child),.content p:not(:last-child),.content pre:not(:last-child),.content table:not(:last-child),.content ul:not(:last-child){margin-bottom:1em}.content h1,.content h2,.content h3,.content h4,.content h5,.content h6{color:#363636;font-weight:600;line-height:1.125}.content h1{font-size:2em;margin-bottom:.5em}.content h1:not(:first-child){margin-top:1em}.content h2{font-size:1.75em;margin-bottom:.5714em}.content h2:not(:first-child){margin-top:1.1428em}.content h3{font-size:1.5em;margin-bottom:.6666em}.content h3:not(:first-child){margin-top:1.3333em}.content h4{font-size:1.25em;margin-bottom:.8em}.content h5{font-size:1.125em;margin-bottom:.8888em}.content h6{font-size:1em;margin-bottom:1em}.content blockquote{background-color:#f5f5f5;border-left:5px solid #dbdbdb;padding:1.25em 1.5em}.content ol{list-style-position:outside;margin-left:2em;margin-top:1em}.content ol:not([type]){list-style-type:decimal}.content ol:not([type]).is-lower-alpha{list-style-type:lower-alpha}.content ol:not([type]).is-lower-roman{list-style-type:lower-roman}.content ol:not([type]).is-upper-alpha{list-style-type:upper-alpha}.content ol:not([type]).is-upper-roman{list-style-type:upper-roman}.content ul{list-style:disc outside;margin-left:2em;margin-top:1em}.content ul ul{list-style-type:circle;margin-top:.5em}.content ul ul ul{list-style-type:square}.content dd{margin-left:2em}.content figure{margin-left:2em;margin-right:2em;text-align:center}.content figure:not(:first-child){margin-top:2em}.content figure:not(:last-child){margin-bottom:2em}.content figure img{display:inline-block}.content figure figcaption{font-style:italic}.content pre{-webkit-overflow-scrolling:touch;overflow-x:auto;padding:1.25em 1.5em;white-space:pre;word-wrap:normal}.content sub,.content sup{font-size:75%}.content table{width:100%}.content table td,.content table th{border:1px solid #dbdbdb;border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.content table th{color:#363636}.content table th:not([align]){text-align:inherit}.content table thead td,.content table thead th{border-width:0 0 2px;color:#363636}.content table tfoot td,.content table tfoot th{border-width:2px 0 0;color:#363636}.content table tbody tr:last-child td,.content table tbody tr:last-child th{border-bottom-width:0}.content .tabs li+li{margin-top:0}.content.is-small{font-size:.75rem}.content.is-medium{font-size:1.25rem}.content.is-large{font-size:1.5rem}.icon{align-items:center;display:inline-flex;justify-content:center;height:1.5rem;width:1.5rem}.icon.is-small{height:1rem;width:1rem}.icon.is-medium{height:2rem;width:2rem}.icon.is-large{height:3rem;width:3rem}.icon-text{align-items:flex-start;color:inherit;display:inline-flex;flex-wrap:wrap;line-height:1.5rem;vertical-align:top}.icon-text .icon{flex-grow:0;flex-shrink:0}.icon-text .icon:not(:last-child){margin-right:.25em}.icon-text .icon:not(:first-child){margin-left:.25em}div.icon-text{display:flex}.image{display:block;position:relative}.image img{display:block;height:auto;width:100%}.image img.is-rounded{border-radius:290486px}.image.is-fullwidth{width:100%}.image.is-16by9 .has-ratio,.image.is-16by9 img,.image.is-1by1 .has-ratio,.image.is-1by1 img,.image.is-1by2 .has-ratio,.image.is-1by2 img,.image.is-1by3 .has-ratio,.image.is-1by3 img,.image.is-2by1 .has-ratio,.image.is-2by1 img,.image.is-2by3 .has-ratio,.image.is-2by3 img,.image.is-3by1 .has-ratio,.image.is-3by1 img,.image.is-3by2 .has-ratio,.image.is-3by2 img,.image.is-3by4 .has-ratio,.image.is-3by4 img,.image.is-3by5 .has-ratio,.image.is-3by5 img,.image.is-4by3 .has-ratio,.image.is-4by3 img,.image.is-4by5 .has-ratio,.image.is-4by5 img,.image.is-5by3 .has-ratio,.image.is-5by3 img,.image.is-5by4 .has-ratio,.image.is-5by4 img,.image.is-9by16 .has-ratio,.image.is-9by16 img,.image.is-square .has-ratio,.image.is-square img{height:100%;width:100%}.image.is-1by1,.image.is-square{padding-top:100%}.image.is-5by4{padding-top:80%}.image.is-4by3{padding-top:75%}.image.is-3by2{padding-top:66.6666%}.image.is-5by3{padding-top:60%}.image.is-16by9{padding-top:56.25%}.image.is-2by1{padding-top:50%}.image.is-3by1{padding-top:33.3333%}.image.is-4by5{padding-top:125%}.image.is-3by4{padding-top:133.3333%}.image.is-2by3{padding-top:150%}.image.is-3by5{padding-top:166.6666%}.image.is-9by16{padding-top:177.7777%}.image.is-1by2{padding-top:200%}.image.is-1by3{padding-top:300%}.image.is-16x16{height:16px;width:16px}.image.is-24x24{height:24px;width:24px}.image.is-32x32{height:32px;width:32px}.image.is-48x48{height:48px;width:48px}.image.is-64x64{height:64px;width:64px}.image.is-96x96{height:96px;width:96px}.image.is-128x128{height:128px;width:128px}.notification{background-color:#f5f5f5;border-radius:4px;position:relative;padding:1.25rem 2.5rem 1.25rem 1.5rem}.notification a:not(.button):not(.dropdown-item){color:currentColor;text-decoration:underline}.notification strong{color:currentColor}.notification code,.notification pre{background:#fff}.notification pre code{background:0 0}.notification>.delete{right:.5rem;position:absolute;top:.5rem}.notification .content,.notification .subtitle,.notification .title{color:currentColor}.notification.is-white{background-color:#fff;color:#0a0a0a}.notification.is-black{background-color:#0a0a0a;color:#fff}.notification.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.notification.is-dark{background-color:#363636;color:#fff}.notification.is-primary{background-color:#00d1b2;color:#fff}.notification.is-primary.is-light{background-color:#ebfffc;color:#00947e}.notification.is-link{background-color:#3273dc;color:#fff}.notification.is-link.is-light{background-color:#eef3fc;color:#2160c4}.notification.is-info{background-color:#3298dc;color:#fff}.notification.is-info.is-light{background-color:#eef6fc;color:#1d72aa}.notification.is-success{background-color:#48c774;color:#fff}.notification.is-success.is-light{background-color:#effaf3;color:#257942}.notification.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.notification.is-warning.is-light{background-color:#fffbeb;color:#947600}.notification.is-danger{background-color:#f14668;color:#fff}.notification.is-danger.is-light{background-color:#feecf0;color:#cc0f35}.progress{-moz-appearance:none;-webkit-appearance:none;border:none;border-radius:290486px;display:block;height:1rem;overflow:hidden;padding:0;width:100%}.progress::-webkit-progress-bar{background-color:#ededed}.progress::-webkit-progress-value{background-color:#4a4a4a}.progress::-moz-progress-bar{background-color:#4a4a4a}.progress::-ms-fill{background-color:#4a4a4a;border:none}.progress.is-white::-webkit-progress-value{background-color:#fff}.progress.is-white::-moz-progress-bar{background-color:#fff}.progress.is-white::-ms-fill{background-color:#fff}.progress.is-white:indeterminate{background-image:linear-gradient(to right,#fff 30%,#ededed 30%)}.progress.is-black::-webkit-progress-value{background-color:#0a0a0a}.progress.is-black::-moz-progress-bar{background-color:#0a0a0a}.progress.is-black::-ms-fill{background-color:#0a0a0a}.progress.is-black:indeterminate{background-image:linear-gradient(to right,#0a0a0a 30%,#ededed 30%)}.progress.is-light::-webkit-progress-value{background-color:#f5f5f5}.progress.is-light::-moz-progress-bar{background-color:#f5f5f5}.progress.is-light::-ms-fill{background-color:#f5f5f5}.progress.is-light:indeterminate{background-image:linear-gradient(to right,#f5f5f5 30%,#ededed 30%)}.progress.is-dark::-webkit-progress-value{background-color:#363636}.progress.is-dark::-moz-progress-bar{background-color:#363636}.progress.is-dark::-ms-fill{background-color:#363636}.progress.is-dark:indeterminate{background-image:linear-gradient(to right,#363636 30%,#ededed 30%)}.progress.is-primary::-webkit-progress-value{background-color:#00d1b2}.progress.is-primary::-moz-progress-bar{background-color:#00d1b2}.progress.is-primary::-ms-fill{background-color:#00d1b2}.progress.is-primary:indeterminate{background-image:linear-gradient(to right,#00d1b2 30%,#ededed 30%)}.progress.is-link::-webkit-progress-value{background-color:#3273dc}.progress.is-link::-moz-progress-bar{background-color:#3273dc}.progress.is-link::-ms-fill{background-color:#3273dc}.progress.is-link:indeterminate{background-image:linear-gradient(to right,#3273dc 30%,#ededed 30%)}.progress.is-info::-webkit-progress-value{background-color:#3298dc}.progress.is-info::-moz-progress-bar{background-color:#3298dc}.progress.is-info::-ms-fill{background-color:#3298dc}.progress.is-info:indeterminate{background-image:linear-gradient(to right,#3298dc 30%,#ededed 30%)}.progress.is-success::-webkit-progress-value{background-color:#48c774}.progress.is-success::-moz-progress-bar{background-color:#48c774}.progress.is-success::-ms-fill{background-color:#48c774}.progress.is-success:indeterminate{background-image:linear-gradient(to right,#48c774 30%,#ededed 30%)}.progress.is-warning::-webkit-progress-value{background-color:#ffdd57}.progress.is-warning::-moz-progress-bar{background-color:#ffdd57}.progress.is-warning::-ms-fill{background-color:#ffdd57}.progress.is-warning:indeterminate{background-image:linear-gradient(to right,#ffdd57 30%,#ededed 30%)}.progress.is-danger::-webkit-progress-value{background-color:#f14668}.progress.is-danger::-moz-progress-bar{background-color:#f14668}.progress.is-danger::-ms-fill{background-color:#f14668}.progress.is-danger:indeterminate{background-image:linear-gradient(to right,#f14668 30%,#ededed 30%)}.progress:indeterminate{-webkit-animation-duration:1.5s;animation-duration:1.5s;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite;-webkit-animation-name:moveIndeterminate;animation-name:moveIndeterminate;-webkit-animation-timing-function:linear;animation-timing-function:linear;background-color:#ededed;background-image:linear-gradient(to right,#4a4a4a 30%,#ededed 30%);background-position:top left;background-repeat:no-repeat;background-size:150% 150%}.progress:indeterminate::-webkit-progress-bar{background-color:transparent}.progress:indeterminate::-moz-progress-bar{background-color:transparent}.progress:indeterminate::-ms-fill{animation-name:none}.progress.is-small{height:.75rem}.progress.is-medium{height:1.25rem}.progress.is-large{height:1.5rem}@-webkit-keyframes moveIndeterminate{from{background-position:200% 0}to{background-position:-200% 0}}@keyframes moveIndeterminate{from{background-position:200% 0}to{background-position:-200% 0}}.table{background-color:#fff;color:#363636}.table td,.table th{border:1px solid #dbdbdb;border-width:0 0 1px;padding:.5em .75em;vertical-align:top}.table td.is-white,.table th.is-white{background-color:#fff;border-color:#fff;color:#0a0a0a}.table td.is-black,.table th.is-black{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.table td.is-light,.table th.is-light{background-color:#f5f5f5;border-color:#f5f5f5;color:rgba(0,0,0,.7)}.table td.is-dark,.table th.is-dark{background-color:#363636;border-color:#363636;color:#fff}.table td.is-primary,.table th.is-primary{background-color:#00d1b2;border-color:#00d1b2;color:#fff}.table td.is-link,.table th.is-link{background-color:#3273dc;border-color:#3273dc;color:#fff}.table td.is-info,.table th.is-info{background-color:#3298dc;border-color:#3298dc;color:#fff}.table td.is-success,.table th.is-success{background-color:#48c774;border-color:#48c774;color:#fff}.table td.is-warning,.table th.is-warning{background-color:#ffdd57;border-color:#ffdd57;color:rgba(0,0,0,.7)}.table td.is-danger,.table th.is-danger{background-color:#f14668;border-color:#f14668;color:#fff}.table td.is-narrow,.table th.is-narrow{white-space:nowrap;width:1%}.table td.is-selected,.table th.is-selected{background-color:#00d1b2;color:#fff}.table td.is-selected a,.table td.is-selected strong,.table th.is-selected a,.table th.is-selected strong{color:currentColor}.table td.is-vcentered,.table th.is-vcentered{vertical-align:middle}.table th{color:#363636}.table th:not([align]){text-align:inherit}.table tr.is-selected{background-color:#00d1b2;color:#fff}.table tr.is-selected a,.table tr.is-selected strong{color:currentColor}.table tr.is-selected td,.table tr.is-selected th{border-color:#fff;color:currentColor}.table thead{background-color:transparent}.table thead td,.table thead th{border-width:0 0 2px;color:#363636}.table tfoot{background-color:transparent}.table tfoot td,.table tfoot th{border-width:2px 0 0;color:#363636}.table tbody{background-color:transparent}.table tbody tr:last-child td,.table tbody tr:last-child th{border-bottom-width:0}.table.is-bordered td,.table.is-bordered th{border-width:1px}.table.is-bordered tr:last-child td,.table.is-bordered tr:last-child th{border-bottom-width:1px}.table.is-fullwidth{width:100%}.table.is-hoverable tbody tr:not(.is-selected):hover{background-color:#fafafa}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover{background-color:#fafafa}.table.is-hoverable.is-striped tbody tr:not(.is-selected):hover:nth-child(even){background-color:#f5f5f5}.table.is-narrow td,.table.is-narrow th{padding:.25em .5em}.table.is-striped tbody tr:not(.is-selected):nth-child(even){background-color:#fafafa}.table-container{-webkit-overflow-scrolling:touch;overflow:auto;overflow-y:hidden;max-width:100%}.tags{align-items:center;display:flex;flex-wrap:wrap;justify-content:flex-start}.tags .tag{margin-bottom:.5rem}.tags .tag:not(:last-child){margin-right:.5rem}.tags:last-child{margin-bottom:-.5rem}.tags:not(:last-child){margin-bottom:1rem}.tags.are-medium .tag:not(.is-normal):not(.is-large){font-size:1rem}.tags.are-large .tag:not(.is-normal):not(.is-medium){font-size:1.25rem}.tags.is-centered{justify-content:center}.tags.is-centered .tag{margin-right:.25rem;margin-left:.25rem}.tags.is-right{justify-content:flex-end}.tags.is-right .tag:not(:first-child){margin-left:.5rem}.tags.is-right .tag:not(:last-child){margin-right:0}.tags.has-addons .tag{margin-right:0}.tags.has-addons .tag:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}.tags.has-addons .tag:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.tag:not(body){align-items:center;background-color:#f5f5f5;border-radius:4px;color:#4a4a4a;display:inline-flex;font-size:.75rem;height:2em;justify-content:center;line-height:1.5;padding-left:.75em;padding-right:.75em;white-space:nowrap}.tag:not(body) .delete{margin-left:.25rem;margin-right:-.375rem}.tag:not(body).is-white{background-color:#fff;color:#0a0a0a}.tag:not(body).is-black{background-color:#0a0a0a;color:#fff}.tag:not(body).is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.tag:not(body).is-dark{background-color:#363636;color:#fff}.tag:not(body).is-primary{background-color:#00d1b2;color:#fff}.tag:not(body).is-primary.is-light{background-color:#ebfffc;color:#00947e}.tag:not(body).is-link{background-color:#3273dc;color:#fff}.tag:not(body).is-link.is-light{background-color:#eef3fc;color:#2160c4}.tag:not(body).is-info{background-color:#3298dc;color:#fff}.tag:not(body).is-info.is-light{background-color:#eef6fc;color:#1d72aa}.tag:not(body).is-success{background-color:#48c774;color:#fff}.tag:not(body).is-success.is-light{background-color:#effaf3;color:#257942}.tag:not(body).is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.tag:not(body).is-warning.is-light{background-color:#fffbeb;color:#947600}.tag:not(body).is-danger{background-color:#f14668;color:#fff}.tag:not(body).is-danger.is-light{background-color:#feecf0;color:#cc0f35}.tag:not(body).is-normal{font-size:.75rem}.tag:not(body).is-medium{font-size:1rem}.tag:not(body).is-large{font-size:1.25rem}.tag:not(body) .icon:first-child:not(:last-child){margin-left:-.375em;margin-right:.1875em}.tag:not(body) .icon:last-child:not(:first-child){margin-left:.1875em;margin-right:-.375em}.tag:not(body) .icon:first-child:last-child{margin-left:-.375em;margin-right:-.375em}.tag:not(body).is-delete{margin-left:1px;padding:0;position:relative;width:2em}.tag:not(body).is-delete::after,.tag:not(body).is-delete::before{background-color:currentColor;content:"";display:block;left:50%;position:absolute;top:50%;transform:translateX(-50%) translateY(-50%) rotate(45deg);transform-origin:center center}.tag:not(body).is-delete::before{height:1px;width:50%}.tag:not(body).is-delete::after{height:50%;width:1px}.tag:not(body).is-delete:focus,.tag:not(body).is-delete:hover{background-color:#e8e8e8}.tag:not(body).is-delete:active{background-color:#dbdbdb}.tag:not(body).is-rounded{border-radius:290486px}a.tag:hover{text-decoration:underline}.subtitle,.title{word-break:break-word}.subtitle em,.subtitle span,.title em,.title span{font-weight:inherit}.subtitle sub,.title sub{font-size:.75em}.subtitle sup,.title sup{font-size:.75em}.subtitle .tag,.title .tag{vertical-align:middle}.title{color:#363636;font-size:2rem;font-weight:600;line-height:1.125}.title strong{color:inherit;font-weight:inherit}.title+.highlight{margin-top:-.75rem}.title:not(.is-spaced)+.subtitle{margin-top:-1.25rem}.title.is-1{font-size:3rem}.title.is-2{font-size:2.5rem}.title.is-3{font-size:2rem}.title.is-4{font-size:1.5rem}.title.is-5{font-size:1.25rem}.title.is-6{font-size:1rem}.title.is-7{font-size:.75rem}.subtitle{color:#4a4a4a;font-size:1.25rem;font-weight:400;line-height:1.25}.subtitle strong{color:#363636;font-weight:600}.subtitle:not(.is-spaced)+.title{margin-top:-1.25rem}.subtitle.is-1{font-size:3rem}.subtitle.is-2{font-size:2.5rem}.subtitle.is-3{font-size:2rem}.subtitle.is-4{font-size:1.5rem}.subtitle.is-5{font-size:1.25rem}.subtitle.is-6{font-size:1rem}.subtitle.is-7{font-size:.75rem}.heading{display:block;font-size:11px;letter-spacing:1px;margin-bottom:5px;text-transform:uppercase}.highlight{font-weight:400;max-width:100%;overflow:hidden;padding:0}.highlight pre{overflow:auto;max-width:100%}.number{align-items:center;background-color:#f5f5f5;border-radius:290486px;display:inline-flex;font-size:1.25rem;height:2em;justify-content:center;margin-right:1.5rem;min-width:2.5em;padding:.25rem .5rem;text-align:center;vertical-align:top}.input,.select select,.textarea{background-color:#fff;border-color:#dbdbdb;border-radius:4px;color:#363636}.input::-moz-placeholder,.select select::-moz-placeholder,.textarea::-moz-placeholder{color:rgba(54,54,54,.3)}.input::-webkit-input-placeholder,.select select::-webkit-input-placeholder,.textarea::-webkit-input-placeholder{color:rgba(54,54,54,.3)}.input:-moz-placeholder,.select select:-moz-placeholder,.textarea:-moz-placeholder{color:rgba(54,54,54,.3)}.input:-ms-input-placeholder,.select select:-ms-input-placeholder,.textarea:-ms-input-placeholder{color:rgba(54,54,54,.3)}.input:hover,.is-hovered.input,.is-hovered.textarea,.select select.is-hovered,.select select:hover,.textarea:hover{border-color:#b5b5b5}.input:active,.input:focus,.is-active.input,.is-active.textarea,.is-focused.input,.is-focused.textarea,.select select.is-active,.select select.is-focused,.select select:active,.select select:focus,.textarea:active,.textarea:focus{border-color:#3273dc;box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.input[disabled],.select fieldset[disabled] select,.select select[disabled],.textarea[disabled],fieldset[disabled] .input,fieldset[disabled] .select select,fieldset[disabled] .textarea{background-color:#f5f5f5;border-color:#f5f5f5;box-shadow:none;color:#7a7a7a}.input[disabled]::-moz-placeholder,.select fieldset[disabled] select::-moz-placeholder,.select select[disabled]::-moz-placeholder,.textarea[disabled]::-moz-placeholder,fieldset[disabled] .input::-moz-placeholder,fieldset[disabled] .select select::-moz-placeholder,fieldset[disabled] .textarea::-moz-placeholder{color:rgba(122,122,122,.3)}.input[disabled]::-webkit-input-placeholder,.select fieldset[disabled] select::-webkit-input-placeholder,.select select[disabled]::-webkit-input-placeholder,.textarea[disabled]::-webkit-input-placeholder,fieldset[disabled] .input::-webkit-input-placeholder,fieldset[disabled] .select select::-webkit-input-placeholder,fieldset[disabled] .textarea::-webkit-input-placeholder{color:rgba(122,122,122,.3)}.input[disabled]:-moz-placeholder,.select fieldset[disabled] select:-moz-placeholder,.select select[disabled]:-moz-placeholder,.textarea[disabled]:-moz-placeholder,fieldset[disabled] .input:-moz-placeholder,fieldset[disabled] .select select:-moz-placeholder,fieldset[disabled] .textarea:-moz-placeholder{color:rgba(122,122,122,.3)}.input[disabled]:-ms-input-placeholder,.select fieldset[disabled] select:-ms-input-placeholder,.select select[disabled]:-ms-input-placeholder,.textarea[disabled]:-ms-input-placeholder,fieldset[disabled] .input:-ms-input-placeholder,fieldset[disabled] .select select:-ms-input-placeholder,fieldset[disabled] .textarea:-ms-input-placeholder{color:rgba(122,122,122,.3)}.input,.textarea{box-shadow:inset 0 .0625em .125em rgba(10,10,10,.05);max-width:100%;width:100%}.input[readonly],.textarea[readonly]{box-shadow:none}.is-white.input,.is-white.textarea{border-color:#fff}.is-white.input:active,.is-white.input:focus,.is-white.is-active.input,.is-white.is-active.textarea,.is-white.is-focused.input,.is-white.is-focused.textarea,.is-white.textarea:active,.is-white.textarea:focus{box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.is-black.input,.is-black.textarea{border-color:#0a0a0a}.is-black.input:active,.is-black.input:focus,.is-black.is-active.input,.is-black.is-active.textarea,.is-black.is-focused.input,.is-black.is-focused.textarea,.is-black.textarea:active,.is-black.textarea:focus{box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.is-light.input,.is-light.textarea{border-color:#f5f5f5}.is-light.input:active,.is-light.input:focus,.is-light.is-active.input,.is-light.is-active.textarea,.is-light.is-focused.input,.is-light.is-focused.textarea,.is-light.textarea:active,.is-light.textarea:focus{box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.is-dark.input,.is-dark.textarea{border-color:#363636}.is-dark.input:active,.is-dark.input:focus,.is-dark.is-active.input,.is-dark.is-active.textarea,.is-dark.is-focused.input,.is-dark.is-focused.textarea,.is-dark.textarea:active,.is-dark.textarea:focus{box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.is-primary.input,.is-primary.textarea{border-color:#00d1b2}.is-primary.input:active,.is-primary.input:focus,.is-primary.is-active.input,.is-primary.is-active.textarea,.is-primary.is-focused.input,.is-primary.is-focused.textarea,.is-primary.textarea:active,.is-primary.textarea:focus{box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.is-link.input,.is-link.textarea{border-color:#3273dc}.is-link.input:active,.is-link.input:focus,.is-link.is-active.input,.is-link.is-active.textarea,.is-link.is-focused.input,.is-link.is-focused.textarea,.is-link.textarea:active,.is-link.textarea:focus{box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.is-info.input,.is-info.textarea{border-color:#3298dc}.is-info.input:active,.is-info.input:focus,.is-info.is-active.input,.is-info.is-active.textarea,.is-info.is-focused.input,.is-info.is-focused.textarea,.is-info.textarea:active,.is-info.textarea:focus{box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.is-success.input,.is-success.textarea{border-color:#48c774}.is-success.input:active,.is-success.input:focus,.is-success.is-active.input,.is-success.is-active.textarea,.is-success.is-focused.input,.is-success.is-focused.textarea,.is-success.textarea:active,.is-success.textarea:focus{box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.is-warning.input,.is-warning.textarea{border-color:#ffdd57}.is-warning.input:active,.is-warning.input:focus,.is-warning.is-active.input,.is-warning.is-active.textarea,.is-warning.is-focused.input,.is-warning.is-focused.textarea,.is-warning.textarea:active,.is-warning.textarea:focus{box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.is-danger.input,.is-danger.textarea{border-color:#f14668}.is-danger.input:active,.is-danger.input:focus,.is-danger.is-active.input,.is-danger.is-active.textarea,.is-danger.is-focused.input,.is-danger.is-focused.textarea,.is-danger.textarea:active,.is-danger.textarea:focus{box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.is-small.input,.is-small.textarea{border-radius:2px;font-size:.75rem}.is-medium.input,.is-medium.textarea{font-size:1.25rem}.is-large.input,.is-large.textarea{font-size:1.5rem}.is-fullwidth.input,.is-fullwidth.textarea{display:block;width:100%}.is-inline.input,.is-inline.textarea{display:inline;width:auto}.input.is-rounded{border-radius:290486px;padding-left:calc(calc(.75em - 1px) + .375em);padding-right:calc(calc(.75em - 1px) + .375em)}.input.is-static{background-color:transparent;border-color:transparent;box-shadow:none;padding-left:0;padding-right:0}.textarea{display:block;max-width:100%;min-width:100%;padding:calc(.75em - 1px);resize:vertical}.textarea:not([rows]){max-height:40em;min-height:8em}.textarea[rows]{height:initial}.textarea.has-fixed-size{resize:none}.checkbox,.radio{cursor:pointer;display:inline-block;line-height:1.25;position:relative}.checkbox input,.radio input{cursor:pointer}.checkbox:hover,.radio:hover{color:#363636}.checkbox input[disabled],.checkbox[disabled],.radio input[disabled],.radio[disabled],fieldset[disabled] .checkbox,fieldset[disabled] .radio{color:#7a7a7a;cursor:not-allowed}.radio+.radio{margin-left:.5em}.select{display:inline-block;max-width:100%;position:relative;vertical-align:top}.select:not(.is-multiple){height:2.5em}.select:not(.is-multiple):not(.is-loading)::after{border-color:#3273dc;right:1.125em;z-index:4}.select.is-rounded select{border-radius:290486px;padding-left:1em}.select select{cursor:pointer;display:block;font-size:1em;max-width:100%;outline:0}.select select::-ms-expand{display:none}.select select[disabled]:hover,fieldset[disabled] .select select:hover{border-color:#f5f5f5}.select select:not([multiple]){padding-right:2.5em}.select select[multiple]{height:auto;padding:0}.select select[multiple] option{padding:.5em 1em}.select:not(.is-multiple):not(.is-loading):hover::after{border-color:#363636}.select.is-white:not(:hover)::after{border-color:#fff}.select.is-white select{border-color:#fff}.select.is-white select.is-hovered,.select.is-white select:hover{border-color:#f2f2f2}.select.is-white select.is-active,.select.is-white select.is-focused,.select.is-white select:active,.select.is-white select:focus{box-shadow:0 0 0 .125em rgba(255,255,255,.25)}.select.is-black:not(:hover)::after{border-color:#0a0a0a}.select.is-black select{border-color:#0a0a0a}.select.is-black select.is-hovered,.select.is-black select:hover{border-color:#000}.select.is-black select.is-active,.select.is-black select.is-focused,.select.is-black select:active,.select.is-black select:focus{box-shadow:0 0 0 .125em rgba(10,10,10,.25)}.select.is-light:not(:hover)::after{border-color:#f5f5f5}.select.is-light select{border-color:#f5f5f5}.select.is-light select.is-hovered,.select.is-light select:hover{border-color:#e8e8e8}.select.is-light select.is-active,.select.is-light select.is-focused,.select.is-light select:active,.select.is-light select:focus{box-shadow:0 0 0 .125em rgba(245,245,245,.25)}.select.is-dark:not(:hover)::after{border-color:#363636}.select.is-dark select{border-color:#363636}.select.is-dark select.is-hovered,.select.is-dark select:hover{border-color:#292929}.select.is-dark select.is-active,.select.is-dark select.is-focused,.select.is-dark select:active,.select.is-dark select:focus{box-shadow:0 0 0 .125em rgba(54,54,54,.25)}.select.is-primary:not(:hover)::after{border-color:#00d1b2}.select.is-primary select{border-color:#00d1b2}.select.is-primary select.is-hovered,.select.is-primary select:hover{border-color:#00b89c}.select.is-primary select.is-active,.select.is-primary select.is-focused,.select.is-primary select:active,.select.is-primary select:focus{box-shadow:0 0 0 .125em rgba(0,209,178,.25)}.select.is-link:not(:hover)::after{border-color:#3273dc}.select.is-link select{border-color:#3273dc}.select.is-link select.is-hovered,.select.is-link select:hover{border-color:#2366d1}.select.is-link select.is-active,.select.is-link select.is-focused,.select.is-link select:active,.select.is-link select:focus{box-shadow:0 0 0 .125em rgba(50,115,220,.25)}.select.is-info:not(:hover)::after{border-color:#3298dc}.select.is-info select{border-color:#3298dc}.select.is-info select.is-hovered,.select.is-info select:hover{border-color:#238cd1}.select.is-info select.is-active,.select.is-info select.is-focused,.select.is-info select:active,.select.is-info select:focus{box-shadow:0 0 0 .125em rgba(50,152,220,.25)}.select.is-success:not(:hover)::after{border-color:#48c774}.select.is-success select{border-color:#48c774}.select.is-success select.is-hovered,.select.is-success select:hover{border-color:#3abb67}.select.is-success select.is-active,.select.is-success select.is-focused,.select.is-success select:active,.select.is-success select:focus{box-shadow:0 0 0 .125em rgba(72,199,116,.25)}.select.is-warning:not(:hover)::after{border-color:#ffdd57}.select.is-warning select{border-color:#ffdd57}.select.is-warning select.is-hovered,.select.is-warning select:hover{border-color:#ffd83d}.select.is-warning select.is-active,.select.is-warning select.is-focused,.select.is-warning select:active,.select.is-warning select:focus{box-shadow:0 0 0 .125em rgba(255,221,87,.25)}.select.is-danger:not(:hover)::after{border-color:#f14668}.select.is-danger select{border-color:#f14668}.select.is-danger select.is-hovered,.select.is-danger select:hover{border-color:#ef2e55}.select.is-danger select.is-active,.select.is-danger select.is-focused,.select.is-danger select:active,.select.is-danger select:focus{box-shadow:0 0 0 .125em rgba(241,70,104,.25)}.select.is-small{border-radius:2px;font-size:.75rem}.select.is-medium{font-size:1.25rem}.select.is-large{font-size:1.5rem}.select.is-disabled::after{border-color:#7a7a7a}.select.is-fullwidth{width:100%}.select.is-fullwidth select{width:100%}.select.is-loading::after{margin-top:0;position:absolute;right:.625em;top:.625em;transform:none}.select.is-loading.is-small:after{font-size:.75rem}.select.is-loading.is-medium:after{font-size:1.25rem}.select.is-loading.is-large:after{font-size:1.5rem}.file{align-items:stretch;display:flex;justify-content:flex-start;position:relative}.file.is-white .file-cta{background-color:#fff;border-color:transparent;color:#0a0a0a}.file.is-white.is-hovered .file-cta,.file.is-white:hover .file-cta{background-color:#f9f9f9;border-color:transparent;color:#0a0a0a}.file.is-white.is-focused .file-cta,.file.is-white:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(255,255,255,.25);color:#0a0a0a}.file.is-white.is-active .file-cta,.file.is-white:active .file-cta{background-color:#f2f2f2;border-color:transparent;color:#0a0a0a}.file.is-black .file-cta{background-color:#0a0a0a;border-color:transparent;color:#fff}.file.is-black.is-hovered .file-cta,.file.is-black:hover .file-cta{background-color:#040404;border-color:transparent;color:#fff}.file.is-black.is-focused .file-cta,.file.is-black:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(10,10,10,.25);color:#fff}.file.is-black.is-active .file-cta,.file.is-black:active .file-cta{background-color:#000;border-color:transparent;color:#fff}.file.is-light .file-cta{background-color:#f5f5f5;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-light.is-hovered .file-cta,.file.is-light:hover .file-cta{background-color:#eee;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-light.is-focused .file-cta,.file.is-light:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(245,245,245,.25);color:rgba(0,0,0,.7)}.file.is-light.is-active .file-cta,.file.is-light:active .file-cta{background-color:#e8e8e8;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-dark .file-cta{background-color:#363636;border-color:transparent;color:#fff}.file.is-dark.is-hovered .file-cta,.file.is-dark:hover .file-cta{background-color:#2f2f2f;border-color:transparent;color:#fff}.file.is-dark.is-focused .file-cta,.file.is-dark:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(54,54,54,.25);color:#fff}.file.is-dark.is-active .file-cta,.file.is-dark:active .file-cta{background-color:#292929;border-color:transparent;color:#fff}.file.is-primary .file-cta{background-color:#00d1b2;border-color:transparent;color:#fff}.file.is-primary.is-hovered .file-cta,.file.is-primary:hover .file-cta{background-color:#00c4a7;border-color:transparent;color:#fff}.file.is-primary.is-focused .file-cta,.file.is-primary:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(0,209,178,.25);color:#fff}.file.is-primary.is-active .file-cta,.file.is-primary:active .file-cta{background-color:#00b89c;border-color:transparent;color:#fff}.file.is-link .file-cta{background-color:#3273dc;border-color:transparent;color:#fff}.file.is-link.is-hovered .file-cta,.file.is-link:hover .file-cta{background-color:#276cda;border-color:transparent;color:#fff}.file.is-link.is-focused .file-cta,.file.is-link:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(50,115,220,.25);color:#fff}.file.is-link.is-active .file-cta,.file.is-link:active .file-cta{background-color:#2366d1;border-color:transparent;color:#fff}.file.is-info .file-cta{background-color:#3298dc;border-color:transparent;color:#fff}.file.is-info.is-hovered .file-cta,.file.is-info:hover .file-cta{background-color:#2793da;border-color:transparent;color:#fff}.file.is-info.is-focused .file-cta,.file.is-info:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(50,152,220,.25);color:#fff}.file.is-info.is-active .file-cta,.file.is-info:active .file-cta{background-color:#238cd1;border-color:transparent;color:#fff}.file.is-success .file-cta{background-color:#48c774;border-color:transparent;color:#fff}.file.is-success.is-hovered .file-cta,.file.is-success:hover .file-cta{background-color:#3ec46d;border-color:transparent;color:#fff}.file.is-success.is-focused .file-cta,.file.is-success:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(72,199,116,.25);color:#fff}.file.is-success.is-active .file-cta,.file.is-success:active .file-cta{background-color:#3abb67;border-color:transparent;color:#fff}.file.is-warning .file-cta{background-color:#ffdd57;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-warning.is-hovered .file-cta,.file.is-warning:hover .file-cta{background-color:#ffdb4a;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-warning.is-focused .file-cta,.file.is-warning:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(255,221,87,.25);color:rgba(0,0,0,.7)}.file.is-warning.is-active .file-cta,.file.is-warning:active .file-cta{background-color:#ffd83d;border-color:transparent;color:rgba(0,0,0,.7)}.file.is-danger .file-cta{background-color:#f14668;border-color:transparent;color:#fff}.file.is-danger.is-hovered .file-cta,.file.is-danger:hover .file-cta{background-color:#f03a5f;border-color:transparent;color:#fff}.file.is-danger.is-focused .file-cta,.file.is-danger:focus .file-cta{border-color:transparent;box-shadow:0 0 .5em rgba(241,70,104,.25);color:#fff}.file.is-danger.is-active .file-cta,.file.is-danger:active .file-cta{background-color:#ef2e55;border-color:transparent;color:#fff}.file.is-small{font-size:.75rem}.file.is-medium{font-size:1.25rem}.file.is-medium .file-icon .fa{font-size:21px}.file.is-large{font-size:1.5rem}.file.is-large .file-icon .fa{font-size:28px}.file.has-name .file-cta{border-bottom-right-radius:0;border-top-right-radius:0}.file.has-name .file-name{border-bottom-left-radius:0;border-top-left-radius:0}.file.has-name.is-empty .file-cta{border-radius:4px}.file.has-name.is-empty .file-name{display:none}.file.is-boxed .file-label{flex-direction:column}.file.is-boxed .file-cta{flex-direction:column;height:auto;padding:1em 3em}.file.is-boxed .file-name{border-width:0 1px 1px}.file.is-boxed .file-icon{height:1.5em;width:1.5em}.file.is-boxed .file-icon .fa{font-size:21px}.file.is-boxed.is-small .file-icon .fa{font-size:14px}.file.is-boxed.is-medium .file-icon .fa{font-size:28px}.file.is-boxed.is-large .file-icon .fa{font-size:35px}.file.is-boxed.has-name .file-cta{border-radius:4px 4px 0 0}.file.is-boxed.has-name .file-name{border-radius:0 0 4px 4px;border-width:0 1px 1px}.file.is-centered{justify-content:center}.file.is-fullwidth .file-label{width:100%}.file.is-fullwidth .file-name{flex-grow:1;max-width:none}.file.is-right{justify-content:flex-end}.file.is-right .file-cta{border-radius:0 4px 4px 0}.file.is-right .file-name{border-radius:4px 0 0 4px;border-width:1px 0 1px 1px;order:-1}.file-label{align-items:stretch;display:flex;cursor:pointer;justify-content:flex-start;overflow:hidden;position:relative}.file-label:hover .file-cta{background-color:#eee;color:#363636}.file-label:hover .file-name{border-color:#d5d5d5}.file-label:active .file-cta{background-color:#e8e8e8;color:#363636}.file-label:active .file-name{border-color:#cfcfcf}.file-input{height:100%;left:0;opacity:0;outline:0;position:absolute;top:0;width:100%}.file-cta,.file-name{border-color:#dbdbdb;border-radius:4px;font-size:1em;padding-left:1em;padding-right:1em;white-space:nowrap}.file-cta{background-color:#f5f5f5;color:#4a4a4a}.file-name{border-color:#dbdbdb;border-style:solid;border-width:1px 1px 1px 0;display:block;max-width:16em;overflow:hidden;text-align:inherit;text-overflow:ellipsis}.file-icon{align-items:center;display:flex;height:1em;justify-content:center;margin-right:.5em;width:1em}.file-icon .fa{font-size:14px}.label{color:#363636;display:block;font-size:1rem;font-weight:700}.label:not(:last-child){margin-bottom:.5em}.label.is-small{font-size:.75rem}.label.is-medium{font-size:1.25rem}.label.is-large{font-size:1.5rem}.help{display:block;font-size:.75rem;margin-top:.25rem}.help.is-white{color:#fff}.help.is-black{color:#0a0a0a}.help.is-light{color:#f5f5f5}.help.is-dark{color:#363636}.help.is-primary{color:#00d1b2}.help.is-link{color:#3273dc}.help.is-info{color:#3298dc}.help.is-success{color:#48c774}.help.is-warning{color:#ffdd57}.help.is-danger{color:#f14668}.field:not(:last-child){margin-bottom:.75rem}.field.has-addons{display:flex;justify-content:flex-start}.field.has-addons .control:not(:last-child){margin-right:-1px}.field.has-addons .control:not(:first-child):not(:last-child) .button,.field.has-addons .control:not(:first-child):not(:last-child) .input,.field.has-addons .control:not(:first-child):not(:last-child) .select select{border-radius:0}.field.has-addons .control:first-child:not(:only-child) .button,.field.has-addons .control:first-child:not(:only-child) .input,.field.has-addons .control:first-child:not(:only-child) .select select{border-bottom-right-radius:0;border-top-right-radius:0}.field.has-addons .control:last-child:not(:only-child) .button,.field.has-addons .control:last-child:not(:only-child) .input,.field.has-addons .control:last-child:not(:only-child) .select select{border-bottom-left-radius:0;border-top-left-radius:0}.field.has-addons .control .button:not([disabled]).is-hovered,.field.has-addons .control .button:not([disabled]):hover,.field.has-addons .control .input:not([disabled]).is-hovered,.field.has-addons .control .input:not([disabled]):hover,.field.has-addons .control .select select:not([disabled]).is-hovered,.field.has-addons .control .select select:not([disabled]):hover{z-index:2}.field.has-addons .control .button:not([disabled]).is-active,.field.has-addons .control .button:not([disabled]).is-focused,.field.has-addons .control .button:not([disabled]):active,.field.has-addons .control .button:not([disabled]):focus,.field.has-addons .control .input:not([disabled]).is-active,.field.has-addons .control .input:not([disabled]).is-focused,.field.has-addons .control .input:not([disabled]):active,.field.has-addons .control .input:not([disabled]):focus,.field.has-addons .control .select select:not([disabled]).is-active,.field.has-addons .control .select select:not([disabled]).is-focused,.field.has-addons .control .select select:not([disabled]):active,.field.has-addons .control .select select:not([disabled]):focus{z-index:3}.field.has-addons .control .button:not([disabled]).is-active:hover,.field.has-addons .control .button:not([disabled]).is-focused:hover,.field.has-addons .control .button:not([disabled]):active:hover,.field.has-addons .control .button:not([disabled]):focus:hover,.field.has-addons .control .input:not([disabled]).is-active:hover,.field.has-addons .control .input:not([disabled]).is-focused:hover,.field.has-addons .control .input:not([disabled]):active:hover,.field.has-addons .control .input:not([disabled]):focus:hover,.field.has-addons .control .select select:not([disabled]).is-active:hover,.field.has-addons .control .select select:not([disabled]).is-focused:hover,.field.has-addons .control .select select:not([disabled]):active:hover,.field.has-addons .control .select select:not([disabled]):focus:hover{z-index:4}.field.has-addons .control.is-expanded{flex-grow:1;flex-shrink:1}.field.has-addons.has-addons-centered{justify-content:center}.field.has-addons.has-addons-right{justify-content:flex-end}.field.has-addons.has-addons-fullwidth .control{flex-grow:1;flex-shrink:0}.field.is-grouped{display:flex;justify-content:flex-start}.field.is-grouped>.control{flex-shrink:0}.field.is-grouped>.control:not(:last-child){margin-bottom:0;margin-right:.75rem}.field.is-grouped>.control.is-expanded{flex-grow:1;flex-shrink:1}.field.is-grouped.is-grouped-centered{justify-content:center}.field.is-grouped.is-grouped-right{justify-content:flex-end}.field.is-grouped.is-grouped-multiline{flex-wrap:wrap}.field.is-grouped.is-grouped-multiline>.control:last-child,.field.is-grouped.is-grouped-multiline>.control:not(:last-child){margin-bottom:.75rem}.field.is-grouped.is-grouped-multiline:last-child{margin-bottom:-.75rem}.field.is-grouped.is-grouped-multiline:not(:last-child){margin-bottom:0}@media screen and (min-width:769px),print{.field.is-horizontal{display:flex}}.field-label .label{font-size:inherit}@media screen and (max-width:768px){.field-label{margin-bottom:.5rem}}@media screen and (min-width:769px),print{.field-label{flex-basis:0;flex-grow:1;flex-shrink:0;margin-right:1.5rem;text-align:right}.field-label.is-small{font-size:.75rem;padding-top:.375em}.field-label.is-normal{padding-top:.375em}.field-label.is-medium{font-size:1.25rem;padding-top:.375em}.field-label.is-large{font-size:1.5rem;padding-top:.375em}}.field-body .field .field{margin-bottom:0}@media screen and (min-width:769px),print{.field-body{display:flex;flex-basis:0;flex-grow:5;flex-shrink:1}.field-body .field{margin-bottom:0}.field-body>.field{flex-shrink:1}.field-body>.field:not(.is-narrow){flex-grow:1}.field-body>.field:not(:last-child){margin-right:.75rem}}.control{box-sizing:border-box;clear:both;font-size:1rem;position:relative;text-align:inherit}.control.has-icons-left .input:focus~.icon,.control.has-icons-left .select:focus~.icon,.control.has-icons-right .input:focus~.icon,.control.has-icons-right .select:focus~.icon{color:#4a4a4a}.control.has-icons-left .input.is-small~.icon,.control.has-icons-left .select.is-small~.icon,.control.has-icons-right .input.is-small~.icon,.control.has-icons-right .select.is-small~.icon{font-size:.75rem}.control.has-icons-left .input.is-medium~.icon,.control.has-icons-left .select.is-medium~.icon,.control.has-icons-right .input.is-medium~.icon,.control.has-icons-right .select.is-medium~.icon{font-size:1.25rem}.control.has-icons-left .input.is-large~.icon,.control.has-icons-left .select.is-large~.icon,.control.has-icons-right .input.is-large~.icon,.control.has-icons-right .select.is-large~.icon{font-size:1.5rem}.control.has-icons-left .icon,.control.has-icons-right .icon{color:#dbdbdb;height:2.5em;pointer-events:none;position:absolute;top:0;width:2.5em;z-index:4}.control.has-icons-left .input,.control.has-icons-left .select select{padding-left:2.5em}.control.has-icons-left .icon.is-left{left:0}.control.has-icons-right .input,.control.has-icons-right .select select{padding-right:2.5em}.control.has-icons-right .icon.is-right{right:0}.control.is-loading::after{position:absolute!important;right:.625em;top:.625em;z-index:4}.control.is-loading.is-small:after{font-size:.75rem}.control.is-loading.is-medium:after{font-size:1.25rem}.control.is-loading.is-large:after{font-size:1.5rem}.breadcrumb{font-size:1rem;white-space:nowrap}.breadcrumb a{align-items:center;color:#3273dc;display:flex;justify-content:center;padding:0 .75em}.breadcrumb a:hover{color:#363636}.breadcrumb li{align-items:center;display:flex}.breadcrumb li:first-child a{padding-left:0}.breadcrumb li.is-active a{color:#363636;cursor:default;pointer-events:none}.breadcrumb li+li::before{color:#b5b5b5;content:"\0002f"}.breadcrumb ol,.breadcrumb ul{align-items:flex-start;display:flex;flex-wrap:wrap;justify-content:flex-start}.breadcrumb .icon:first-child{margin-right:.5em}.breadcrumb .icon:last-child{margin-left:.5em}.breadcrumb.is-centered ol,.breadcrumb.is-centered ul{justify-content:center}.breadcrumb.is-right ol,.breadcrumb.is-right ul{justify-content:flex-end}.breadcrumb.is-small{font-size:.75rem}.breadcrumb.is-medium{font-size:1.25rem}.breadcrumb.is-large{font-size:1.5rem}.breadcrumb.has-arrow-separator li+li::before{content:"\02192"}.breadcrumb.has-bullet-separator li+li::before{content:"\02022"}.breadcrumb.has-dot-separator li+li::before{content:"\000b7"}.breadcrumb.has-succeeds-separator li+li::before{content:"\0227B"}.card{background-color:#fff;border-radius:.25rem;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);color:#4a4a4a;max-width:100%;position:relative}.card-content:first-child,.card-footer:first-child,.card-header:first-child{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card-content:last-child,.card-footer:last-child,.card-header:last-child{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.card-header{background-color:transparent;align-items:stretch;box-shadow:0 .125em .25em rgba(10,10,10,.1);display:flex}.card-header-title{align-items:center;color:#363636;display:flex;flex-grow:1;font-weight:700;padding:.75rem 1rem}.card-header-title.is-centered{justify-content:center}.card-header-icon{align-items:center;cursor:pointer;display:flex;justify-content:center;padding:.75rem 1rem}.card-image{display:block;position:relative}.card-image:first-child img{border-top-left-radius:.25rem;border-top-right-radius:.25rem}.card-image:last-child img{border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.card-content{background-color:transparent;padding:1.5rem}.card-footer{background-color:transparent;border-top:1px solid #ededed;align-items:stretch;display:flex}.card-footer-item{align-items:center;display:flex;flex-basis:0;flex-grow:1;flex-shrink:0;justify-content:center;padding:.75rem}.card-footer-item:not(:last-child){border-right:1px solid #ededed}.card .media:not(:last-child){margin-bottom:1.5rem}.dropdown{display:inline-flex;position:relative;vertical-align:top}.dropdown.is-active .dropdown-menu,.dropdown.is-hoverable:hover .dropdown-menu{display:block}.dropdown.is-right .dropdown-menu{left:auto;right:0}.dropdown.is-up .dropdown-menu{bottom:100%;padding-bottom:4px;padding-top:initial;top:auto}.dropdown-menu{display:none;left:0;min-width:12rem;padding-top:4px;position:absolute;top:100%;z-index:20}.dropdown-content{background-color:#fff;border-radius:4px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);padding-bottom:.5rem;padding-top:.5rem}.dropdown-item{color:#4a4a4a;display:block;font-size:.875rem;line-height:1.5;padding:.375rem 1rem;position:relative}a.dropdown-item,button.dropdown-item{padding-right:3rem;text-align:inherit;white-space:nowrap;width:100%}a.dropdown-item:hover,button.dropdown-item:hover{background-color:#f5f5f5;color:#0a0a0a}a.dropdown-item.is-active,button.dropdown-item.is-active{background-color:#3273dc;color:#fff}.dropdown-divider{background-color:#ededed;border:none;display:block;height:1px;margin:.5rem 0}.level{align-items:center;justify-content:space-between}.level code{border-radius:4px}.level img{display:inline-block;vertical-align:top}.level.is-mobile{display:flex}.level.is-mobile .level-left,.level.is-mobile .level-right{display:flex}.level.is-mobile .level-left+.level-right{margin-top:0}.level.is-mobile .level-item:not(:last-child){margin-bottom:0;margin-right:.75rem}.level.is-mobile .level-item:not(.is-narrow){flex-grow:1}@media screen and (min-width:769px),print{.level{display:flex}.level>.level-item:not(.is-narrow){flex-grow:1}}.level-item{align-items:center;display:flex;flex-basis:auto;flex-grow:0;flex-shrink:0;justify-content:center}.level-item .subtitle,.level-item .title{margin-bottom:0}@media screen and (max-width:768px){.level-item:not(:last-child){margin-bottom:.75rem}}.level-left,.level-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.level-left .level-item.is-flexible,.level-right .level-item.is-flexible{flex-grow:1}@media screen and (min-width:769px),print{.level-left .level-item:not(:last-child),.level-right .level-item:not(:last-child){margin-right:.75rem}}.level-left{align-items:center;justify-content:flex-start}@media screen and (max-width:768px){.level-left+.level-right{margin-top:1.5rem}}@media screen and (min-width:769px),print{.level-left{display:flex}}.level-right{align-items:center;justify-content:flex-end}@media screen and (min-width:769px),print{.level-right{display:flex}}.media{align-items:flex-start;display:flex;text-align:inherit}.media .content:not(:last-child){margin-bottom:.75rem}.media .media{border-top:1px solid rgba(219,219,219,.5);display:flex;padding-top:.75rem}.media .media .content:not(:last-child),.media .media .control:not(:last-child){margin-bottom:.5rem}.media .media .media{padding-top:.5rem}.media .media .media+.media{margin-top:.5rem}.media+.media{border-top:1px solid rgba(219,219,219,.5);margin-top:1rem;padding-top:1rem}.media.is-large+.media{margin-top:1.5rem;padding-top:1.5rem}.media-left,.media-right{flex-basis:auto;flex-grow:0;flex-shrink:0}.media-left{margin-right:1rem}.media-right{margin-left:1rem}.media-content{flex-basis:auto;flex-grow:1;flex-shrink:1;text-align:inherit}@media screen and (max-width:768px){.media-content{overflow-x:auto}}.menu{font-size:1rem}.menu.is-small{font-size:.75rem}.menu.is-medium{font-size:1.25rem}.menu.is-large{font-size:1.5rem}.menu-list{line-height:1.25}.menu-list a{border-radius:2px;color:#4a4a4a;display:block;padding:.5em .75em}.menu-list a:hover{background-color:#f5f5f5;color:#363636}.menu-list a.is-active{background-color:#3273dc;color:#fff}.menu-list li ul{border-left:1px solid #dbdbdb;margin:.75em;padding-left:.75em}.menu-label{color:#7a7a7a;font-size:.75em;letter-spacing:.1em;text-transform:uppercase}.menu-label:not(:first-child){margin-top:1em}.menu-label:not(:last-child){margin-bottom:1em}.message{background-color:#f5f5f5;border-radius:4px;font-size:1rem}.message strong{color:currentColor}.message a:not(.button):not(.tag):not(.dropdown-item){color:currentColor;text-decoration:underline}.message.is-small{font-size:.75rem}.message.is-medium{font-size:1.25rem}.message.is-large{font-size:1.5rem}.message.is-white{background-color:#fff}.message.is-white .message-header{background-color:#fff;color:#0a0a0a}.message.is-white .message-body{border-color:#fff}.message.is-black{background-color:#fafafa}.message.is-black .message-header{background-color:#0a0a0a;color:#fff}.message.is-black .message-body{border-color:#0a0a0a}.message.is-light{background-color:#fafafa}.message.is-light .message-header{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.message.is-light .message-body{border-color:#f5f5f5}.message.is-dark{background-color:#fafafa}.message.is-dark .message-header{background-color:#363636;color:#fff}.message.is-dark .message-body{border-color:#363636}.message.is-primary{background-color:#ebfffc}.message.is-primary .message-header{background-color:#00d1b2;color:#fff}.message.is-primary .message-body{border-color:#00d1b2;color:#00947e}.message.is-link{background-color:#eef3fc}.message.is-link .message-header{background-color:#3273dc;color:#fff}.message.is-link .message-body{border-color:#3273dc;color:#2160c4}.message.is-info{background-color:#eef6fc}.message.is-info .message-header{background-color:#3298dc;color:#fff}.message.is-info .message-body{border-color:#3298dc;color:#1d72aa}.message.is-success{background-color:#effaf3}.message.is-success .message-header{background-color:#48c774;color:#fff}.message.is-success .message-body{border-color:#48c774;color:#257942}.message.is-warning{background-color:#fffbeb}.message.is-warning .message-header{background-color:#ffdd57;color:rgba(0,0,0,.7)}.message.is-warning .message-body{border-color:#ffdd57;color:#947600}.message.is-danger{background-color:#feecf0}.message.is-danger .message-header{background-color:#f14668;color:#fff}.message.is-danger .message-body{border-color:#f14668;color:#cc0f35}.message-header{align-items:center;background-color:#4a4a4a;border-radius:4px 4px 0 0;color:#fff;display:flex;font-weight:700;justify-content:space-between;line-height:1.25;padding:.75em 1em;position:relative}.message-header .delete{flex-grow:0;flex-shrink:0;margin-left:.75em}.message-header+.message-body{border-width:0;border-top-left-radius:0;border-top-right-radius:0}.message-body{border-color:#dbdbdb;border-radius:4px;border-style:solid;border-width:0 0 0 4px;color:#4a4a4a;padding:1.25em 1.5em}.message-body code,.message-body pre{background-color:#fff}.message-body pre code{background-color:transparent}.modal{align-items:center;display:none;flex-direction:column;justify-content:center;overflow:hidden;position:fixed;z-index:40}.modal.is-active{display:flex}.modal-background{background-color:rgba(10,10,10,.86)}.modal-card,.modal-content{margin:0 20px;max-height:calc(100vh - 160px);overflow:auto;position:relative;width:100%}@media screen and (min-width:769px){.modal-card,.modal-content{margin:0 auto;max-height:calc(100vh - 40px);width:640px}}.modal-close{background:0 0;height:40px;position:fixed;right:20px;top:20px;width:40px}.modal-card{display:flex;flex-direction:column;max-height:calc(100vh - 40px);overflow:hidden;-ms-overflow-y:visible}.modal-card-foot,.modal-card-head{align-items:center;background-color:#f5f5f5;display:flex;flex-shrink:0;justify-content:flex-start;padding:20px;position:relative}.modal-card-head{border-bottom:1px solid #dbdbdb;border-top-left-radius:6px;border-top-right-radius:6px}.modal-card-title{color:#363636;flex-grow:1;flex-shrink:0;font-size:1.5rem;line-height:1}.modal-card-foot{border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:1px solid #dbdbdb}.modal-card-foot .button:not(:last-child){margin-right:.5em}.modal-card-body{-webkit-overflow-scrolling:touch;background-color:#fff;flex-grow:1;flex-shrink:1;overflow:auto;padding:20px}.navbar{background-color:#fff;min-height:3.25rem;position:relative;z-index:30}.navbar.is-white{background-color:#fff;color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link,.navbar.is-white .navbar-brand>.navbar-item{color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link.is-active,.navbar.is-white .navbar-brand .navbar-link:focus,.navbar.is-white .navbar-brand .navbar-link:hover,.navbar.is-white .navbar-brand>a.navbar-item.is-active,.navbar.is-white .navbar-brand>a.navbar-item:focus,.navbar.is-white .navbar-brand>a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-brand .navbar-link::after{border-color:#0a0a0a}.navbar.is-white .navbar-burger{color:#0a0a0a}@media screen and (min-width:1024px){.navbar.is-white .navbar-end .navbar-link,.navbar.is-white .navbar-end>.navbar-item,.navbar.is-white .navbar-start .navbar-link,.navbar.is-white .navbar-start>.navbar-item{color:#0a0a0a}.navbar.is-white .navbar-end .navbar-link.is-active,.navbar.is-white .navbar-end .navbar-link:focus,.navbar.is-white .navbar-end .navbar-link:hover,.navbar.is-white .navbar-end>a.navbar-item.is-active,.navbar.is-white .navbar-end>a.navbar-item:focus,.navbar.is-white .navbar-end>a.navbar-item:hover,.navbar.is-white .navbar-start .navbar-link.is-active,.navbar.is-white .navbar-start .navbar-link:focus,.navbar.is-white .navbar-start .navbar-link:hover,.navbar.is-white .navbar-start>a.navbar-item.is-active,.navbar.is-white .navbar-start>a.navbar-item:focus,.navbar.is-white .navbar-start>a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-end .navbar-link::after,.navbar.is-white .navbar-start .navbar-link::after{border-color:#0a0a0a}.navbar.is-white .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-white .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-white .navbar-item.has-dropdown:hover .navbar-link{background-color:#f2f2f2;color:#0a0a0a}.navbar.is-white .navbar-dropdown a.navbar-item.is-active{background-color:#fff;color:#0a0a0a}}.navbar.is-black{background-color:#0a0a0a;color:#fff}.navbar.is-black .navbar-brand .navbar-link,.navbar.is-black .navbar-brand>.navbar-item{color:#fff}.navbar.is-black .navbar-brand .navbar-link.is-active,.navbar.is-black .navbar-brand .navbar-link:focus,.navbar.is-black .navbar-brand .navbar-link:hover,.navbar.is-black .navbar-brand>a.navbar-item.is-active,.navbar.is-black .navbar-brand>a.navbar-item:focus,.navbar.is-black .navbar-brand>a.navbar-item:hover{background-color:#000;color:#fff}.navbar.is-black .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-black .navbar-end .navbar-link,.navbar.is-black .navbar-end>.navbar-item,.navbar.is-black .navbar-start .navbar-link,.navbar.is-black .navbar-start>.navbar-item{color:#fff}.navbar.is-black .navbar-end .navbar-link.is-active,.navbar.is-black .navbar-end .navbar-link:focus,.navbar.is-black .navbar-end .navbar-link:hover,.navbar.is-black .navbar-end>a.navbar-item.is-active,.navbar.is-black .navbar-end>a.navbar-item:focus,.navbar.is-black .navbar-end>a.navbar-item:hover,.navbar.is-black .navbar-start .navbar-link.is-active,.navbar.is-black .navbar-start .navbar-link:focus,.navbar.is-black .navbar-start .navbar-link:hover,.navbar.is-black .navbar-start>a.navbar-item.is-active,.navbar.is-black .navbar-start>a.navbar-item:focus,.navbar.is-black .navbar-start>a.navbar-item:hover{background-color:#000;color:#fff}.navbar.is-black .navbar-end .navbar-link::after,.navbar.is-black .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-black .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-black .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-black .navbar-item.has-dropdown:hover .navbar-link{background-color:#000;color:#fff}.navbar.is-black .navbar-dropdown a.navbar-item.is-active{background-color:#0a0a0a;color:#fff}}.navbar.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link,.navbar.is-light .navbar-brand>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link.is-active,.navbar.is-light .navbar-brand .navbar-link:focus,.navbar.is-light .navbar-brand .navbar-link:hover,.navbar.is-light .navbar-brand>a.navbar-item.is-active,.navbar.is-light .navbar-brand>a.navbar-item:focus,.navbar.is-light .navbar-brand>a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width:1024px){.navbar.is-light .navbar-end .navbar-link,.navbar.is-light .navbar-end>.navbar-item,.navbar.is-light .navbar-start .navbar-link,.navbar.is-light .navbar-start>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-light .navbar-end .navbar-link.is-active,.navbar.is-light .navbar-end .navbar-link:focus,.navbar.is-light .navbar-end .navbar-link:hover,.navbar.is-light .navbar-end>a.navbar-item.is-active,.navbar.is-light .navbar-end>a.navbar-item:focus,.navbar.is-light .navbar-end>a.navbar-item:hover,.navbar.is-light .navbar-start .navbar-link.is-active,.navbar.is-light .navbar-start .navbar-link:focus,.navbar.is-light .navbar-start .navbar-link:hover,.navbar.is-light .navbar-start>a.navbar-item.is-active,.navbar.is-light .navbar-start>a.navbar-item:focus,.navbar.is-light .navbar-start>a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-end .navbar-link::after,.navbar.is-light .navbar-start .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-light .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-light .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-light .navbar-item.has-dropdown:hover .navbar-link{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.navbar.is-light .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:rgba(0,0,0,.7)}}.navbar.is-dark{background-color:#363636;color:#fff}.navbar.is-dark .navbar-brand .navbar-link,.navbar.is-dark .navbar-brand>.navbar-item{color:#fff}.navbar.is-dark .navbar-brand .navbar-link.is-active,.navbar.is-dark .navbar-brand .navbar-link:focus,.navbar.is-dark .navbar-brand .navbar-link:hover,.navbar.is-dark .navbar-brand>a.navbar-item.is-active,.navbar.is-dark .navbar-brand>a.navbar-item:focus,.navbar.is-dark .navbar-brand>a.navbar-item:hover{background-color:#292929;color:#fff}.navbar.is-dark .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-dark .navbar-end .navbar-link,.navbar.is-dark .navbar-end>.navbar-item,.navbar.is-dark .navbar-start .navbar-link,.navbar.is-dark .navbar-start>.navbar-item{color:#fff}.navbar.is-dark .navbar-end .navbar-link.is-active,.navbar.is-dark .navbar-end .navbar-link:focus,.navbar.is-dark .navbar-end .navbar-link:hover,.navbar.is-dark .navbar-end>a.navbar-item.is-active,.navbar.is-dark .navbar-end>a.navbar-item:focus,.navbar.is-dark .navbar-end>a.navbar-item:hover,.navbar.is-dark .navbar-start .navbar-link.is-active,.navbar.is-dark .navbar-start .navbar-link:focus,.navbar.is-dark .navbar-start .navbar-link:hover,.navbar.is-dark .navbar-start>a.navbar-item.is-active,.navbar.is-dark .navbar-start>a.navbar-item:focus,.navbar.is-dark .navbar-start>a.navbar-item:hover{background-color:#292929;color:#fff}.navbar.is-dark .navbar-end .navbar-link::after,.navbar.is-dark .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-dark .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-dark .navbar-item.has-dropdown:hover .navbar-link{background-color:#292929;color:#fff}.navbar.is-dark .navbar-dropdown a.navbar-item.is-active{background-color:#363636;color:#fff}}.navbar.is-primary{background-color:#00d1b2;color:#fff}.navbar.is-primary .navbar-brand .navbar-link,.navbar.is-primary .navbar-brand>.navbar-item{color:#fff}.navbar.is-primary .navbar-brand .navbar-link.is-active,.navbar.is-primary .navbar-brand .navbar-link:focus,.navbar.is-primary .navbar-brand .navbar-link:hover,.navbar.is-primary .navbar-brand>a.navbar-item.is-active,.navbar.is-primary .navbar-brand>a.navbar-item:focus,.navbar.is-primary .navbar-brand>a.navbar-item:hover{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-primary .navbar-end .navbar-link,.navbar.is-primary .navbar-end>.navbar-item,.navbar.is-primary .navbar-start .navbar-link,.navbar.is-primary .navbar-start>.navbar-item{color:#fff}.navbar.is-primary .navbar-end .navbar-link.is-active,.navbar.is-primary .navbar-end .navbar-link:focus,.navbar.is-primary .navbar-end .navbar-link:hover,.navbar.is-primary .navbar-end>a.navbar-item.is-active,.navbar.is-primary .navbar-end>a.navbar-item:focus,.navbar.is-primary .navbar-end>a.navbar-item:hover,.navbar.is-primary .navbar-start .navbar-link.is-active,.navbar.is-primary .navbar-start .navbar-link:focus,.navbar.is-primary .navbar-start .navbar-link:hover,.navbar.is-primary .navbar-start>a.navbar-item.is-active,.navbar.is-primary .navbar-start>a.navbar-item:focus,.navbar.is-primary .navbar-start>a.navbar-item:hover{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-end .navbar-link::after,.navbar.is-primary .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-primary .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-primary .navbar-item.has-dropdown:hover .navbar-link{background-color:#00b89c;color:#fff}.navbar.is-primary .navbar-dropdown a.navbar-item.is-active{background-color:#00d1b2;color:#fff}}.navbar.is-link{background-color:#3273dc;color:#fff}.navbar.is-link .navbar-brand .navbar-link,.navbar.is-link .navbar-brand>.navbar-item{color:#fff}.navbar.is-link .navbar-brand .navbar-link.is-active,.navbar.is-link .navbar-brand .navbar-link:focus,.navbar.is-link .navbar-brand .navbar-link:hover,.navbar.is-link .navbar-brand>a.navbar-item.is-active,.navbar.is-link .navbar-brand>a.navbar-item:focus,.navbar.is-link .navbar-brand>a.navbar-item:hover{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-link .navbar-end .navbar-link,.navbar.is-link .navbar-end>.navbar-item,.navbar.is-link .navbar-start .navbar-link,.navbar.is-link .navbar-start>.navbar-item{color:#fff}.navbar.is-link .navbar-end .navbar-link.is-active,.navbar.is-link .navbar-end .navbar-link:focus,.navbar.is-link .navbar-end .navbar-link:hover,.navbar.is-link .navbar-end>a.navbar-item.is-active,.navbar.is-link .navbar-end>a.navbar-item:focus,.navbar.is-link .navbar-end>a.navbar-item:hover,.navbar.is-link .navbar-start .navbar-link.is-active,.navbar.is-link .navbar-start .navbar-link:focus,.navbar.is-link .navbar-start .navbar-link:hover,.navbar.is-link .navbar-start>a.navbar-item.is-active,.navbar.is-link .navbar-start>a.navbar-item:focus,.navbar.is-link .navbar-start>a.navbar-item:hover{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-end .navbar-link::after,.navbar.is-link .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-link .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-link .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-link .navbar-item.has-dropdown:hover .navbar-link{background-color:#2366d1;color:#fff}.navbar.is-link .navbar-dropdown a.navbar-item.is-active{background-color:#3273dc;color:#fff}}.navbar.is-info{background-color:#3298dc;color:#fff}.navbar.is-info .navbar-brand .navbar-link,.navbar.is-info .navbar-brand>.navbar-item{color:#fff}.navbar.is-info .navbar-brand .navbar-link.is-active,.navbar.is-info .navbar-brand .navbar-link:focus,.navbar.is-info .navbar-brand .navbar-link:hover,.navbar.is-info .navbar-brand>a.navbar-item.is-active,.navbar.is-info .navbar-brand>a.navbar-item:focus,.navbar.is-info .navbar-brand>a.navbar-item:hover{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-info .navbar-end .navbar-link,.navbar.is-info .navbar-end>.navbar-item,.navbar.is-info .navbar-start .navbar-link,.navbar.is-info .navbar-start>.navbar-item{color:#fff}.navbar.is-info .navbar-end .navbar-link.is-active,.navbar.is-info .navbar-end .navbar-link:focus,.navbar.is-info .navbar-end .navbar-link:hover,.navbar.is-info .navbar-end>a.navbar-item.is-active,.navbar.is-info .navbar-end>a.navbar-item:focus,.navbar.is-info .navbar-end>a.navbar-item:hover,.navbar.is-info .navbar-start .navbar-link.is-active,.navbar.is-info .navbar-start .navbar-link:focus,.navbar.is-info .navbar-start .navbar-link:hover,.navbar.is-info .navbar-start>a.navbar-item.is-active,.navbar.is-info .navbar-start>a.navbar-item:focus,.navbar.is-info .navbar-start>a.navbar-item:hover{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-end .navbar-link::after,.navbar.is-info .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-info .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-info .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-info .navbar-item.has-dropdown:hover .navbar-link{background-color:#238cd1;color:#fff}.navbar.is-info .navbar-dropdown a.navbar-item.is-active{background-color:#3298dc;color:#fff}}.navbar.is-success{background-color:#48c774;color:#fff}.navbar.is-success .navbar-brand .navbar-link,.navbar.is-success .navbar-brand>.navbar-item{color:#fff}.navbar.is-success .navbar-brand .navbar-link.is-active,.navbar.is-success .navbar-brand .navbar-link:focus,.navbar.is-success .navbar-brand .navbar-link:hover,.navbar.is-success .navbar-brand>a.navbar-item.is-active,.navbar.is-success .navbar-brand>a.navbar-item:focus,.navbar.is-success .navbar-brand>a.navbar-item:hover{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-success .navbar-end .navbar-link,.navbar.is-success .navbar-end>.navbar-item,.navbar.is-success .navbar-start .navbar-link,.navbar.is-success .navbar-start>.navbar-item{color:#fff}.navbar.is-success .navbar-end .navbar-link.is-active,.navbar.is-success .navbar-end .navbar-link:focus,.navbar.is-success .navbar-end .navbar-link:hover,.navbar.is-success .navbar-end>a.navbar-item.is-active,.navbar.is-success .navbar-end>a.navbar-item:focus,.navbar.is-success .navbar-end>a.navbar-item:hover,.navbar.is-success .navbar-start .navbar-link.is-active,.navbar.is-success .navbar-start .navbar-link:focus,.navbar.is-success .navbar-start .navbar-link:hover,.navbar.is-success .navbar-start>a.navbar-item.is-active,.navbar.is-success .navbar-start>a.navbar-item:focus,.navbar.is-success .navbar-start>a.navbar-item:hover{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-end .navbar-link::after,.navbar.is-success .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-success .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-success .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-success .navbar-item.has-dropdown:hover .navbar-link{background-color:#3abb67;color:#fff}.navbar.is-success .navbar-dropdown a.navbar-item.is-active{background-color:#48c774;color:#fff}}.navbar.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link,.navbar.is-warning .navbar-brand>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link.is-active,.navbar.is-warning .navbar-brand .navbar-link:focus,.navbar.is-warning .navbar-brand .navbar-link:hover,.navbar.is-warning .navbar-brand>a.navbar-item.is-active,.navbar.is-warning .navbar-brand>a.navbar-item:focus,.navbar.is-warning .navbar-brand>a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-brand .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-burger{color:rgba(0,0,0,.7)}@media screen and (min-width:1024px){.navbar.is-warning .navbar-end .navbar-link,.navbar.is-warning .navbar-end>.navbar-item,.navbar.is-warning .navbar-start .navbar-link,.navbar.is-warning .navbar-start>.navbar-item{color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-end .navbar-link.is-active,.navbar.is-warning .navbar-end .navbar-link:focus,.navbar.is-warning .navbar-end .navbar-link:hover,.navbar.is-warning .navbar-end>a.navbar-item.is-active,.navbar.is-warning .navbar-end>a.navbar-item:focus,.navbar.is-warning .navbar-end>a.navbar-item:hover,.navbar.is-warning .navbar-start .navbar-link.is-active,.navbar.is-warning .navbar-start .navbar-link:focus,.navbar.is-warning .navbar-start .navbar-link:hover,.navbar.is-warning .navbar-start>a.navbar-item.is-active,.navbar.is-warning .navbar-start>a.navbar-item:focus,.navbar.is-warning .navbar-start>a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-end .navbar-link::after,.navbar.is-warning .navbar-start .navbar-link::after{border-color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-warning .navbar-item.has-dropdown:hover .navbar-link{background-color:#ffd83d;color:rgba(0,0,0,.7)}.navbar.is-warning .navbar-dropdown a.navbar-item.is-active{background-color:#ffdd57;color:rgba(0,0,0,.7)}}.navbar.is-danger{background-color:#f14668;color:#fff}.navbar.is-danger .navbar-brand .navbar-link,.navbar.is-danger .navbar-brand>.navbar-item{color:#fff}.navbar.is-danger .navbar-brand .navbar-link.is-active,.navbar.is-danger .navbar-brand .navbar-link:focus,.navbar.is-danger .navbar-brand .navbar-link:hover,.navbar.is-danger .navbar-brand>a.navbar-item.is-active,.navbar.is-danger .navbar-brand>a.navbar-item:focus,.navbar.is-danger .navbar-brand>a.navbar-item:hover{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-brand .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-burger{color:#fff}@media screen and (min-width:1024px){.navbar.is-danger .navbar-end .navbar-link,.navbar.is-danger .navbar-end>.navbar-item,.navbar.is-danger .navbar-start .navbar-link,.navbar.is-danger .navbar-start>.navbar-item{color:#fff}.navbar.is-danger .navbar-end .navbar-link.is-active,.navbar.is-danger .navbar-end .navbar-link:focus,.navbar.is-danger .navbar-end .navbar-link:hover,.navbar.is-danger .navbar-end>a.navbar-item.is-active,.navbar.is-danger .navbar-end>a.navbar-item:focus,.navbar.is-danger .navbar-end>a.navbar-item:hover,.navbar.is-danger .navbar-start .navbar-link.is-active,.navbar.is-danger .navbar-start .navbar-link:focus,.navbar.is-danger .navbar-start .navbar-link:hover,.navbar.is-danger .navbar-start>a.navbar-item.is-active,.navbar.is-danger .navbar-start>a.navbar-item:focus,.navbar.is-danger .navbar-start>a.navbar-item:hover{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-end .navbar-link::after,.navbar.is-danger .navbar-start .navbar-link::after{border-color:#fff}.navbar.is-danger .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:focus .navbar-link,.navbar.is-danger .navbar-item.has-dropdown:hover .navbar-link{background-color:#ef2e55;color:#fff}.navbar.is-danger .navbar-dropdown a.navbar-item.is-active{background-color:#f14668;color:#fff}}.navbar>.container{align-items:stretch;display:flex;min-height:3.25rem;width:100%}.navbar.has-shadow{box-shadow:0 2px 0 0 #f5f5f5}.navbar.is-fixed-bottom,.navbar.is-fixed-top{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom{bottom:0}.navbar.is-fixed-bottom.has-shadow{box-shadow:0 -2px 0 0 #f5f5f5}.navbar.is-fixed-top{top:0}body.has-navbar-fixed-top,html.has-navbar-fixed-top{padding-top:3.25rem}body.has-navbar-fixed-bottom,html.has-navbar-fixed-bottom{padding-bottom:3.25rem}.navbar-brand,.navbar-tabs{align-items:stretch;display:flex;flex-shrink:0;min-height:3.25rem}.navbar-brand a.navbar-item:focus,.navbar-brand a.navbar-item:hover{background-color:transparent}.navbar-tabs{-webkit-overflow-scrolling:touch;max-width:100vw;overflow-x:auto;overflow-y:hidden}.navbar-burger{color:#4a4a4a;cursor:pointer;display:block;height:3.25rem;position:relative;width:3.25rem;margin-left:auto}.navbar-burger span{background-color:currentColor;display:block;height:1px;left:calc(50% - 8px);position:absolute;transform-origin:center;transition-duration:86ms;transition-property:background-color,opacity,transform;transition-timing-function:ease-out;width:16px}.navbar-burger span:nth-child(1){top:calc(50% - 6px)}.navbar-burger span:nth-child(2){top:calc(50% - 1px)}.navbar-burger span:nth-child(3){top:calc(50% + 4px)}.navbar-burger:hover{background-color:rgba(0,0,0,.05)}.navbar-burger.is-active span:nth-child(1){transform:translateY(5px) rotate(45deg)}.navbar-burger.is-active span:nth-child(2){opacity:0}.navbar-burger.is-active span:nth-child(3){transform:translateY(-5px) rotate(-45deg)}.navbar-menu{display:none}.navbar-item,.navbar-link{color:#4a4a4a;display:block;line-height:1.5;padding:.5rem .75rem;position:relative}.navbar-item .icon:only-child,.navbar-link .icon:only-child{margin-left:-.25rem;margin-right:-.25rem}.navbar-link,a.navbar-item{cursor:pointer}.navbar-link.is-active,.navbar-link:focus,.navbar-link:focus-within,.navbar-link:hover,a.navbar-item.is-active,a.navbar-item:focus,a.navbar-item:focus-within,a.navbar-item:hover{background-color:#fafafa;color:#3273dc}.navbar-item{flex-grow:0;flex-shrink:0}.navbar-item img{max-height:1.75rem}.navbar-item.has-dropdown{padding:0}.navbar-item.is-expanded{flex-grow:1;flex-shrink:1}.navbar-item.is-tab{border-bottom:1px solid transparent;min-height:3.25rem;padding-bottom:calc(.5rem - 1px)}.navbar-item.is-tab:focus,.navbar-item.is-tab:hover{background-color:transparent;border-bottom-color:#3273dc}.navbar-item.is-tab.is-active{background-color:transparent;border-bottom-color:#3273dc;border-bottom-style:solid;border-bottom-width:3px;color:#3273dc;padding-bottom:calc(.5rem - 3px)}.navbar-content{flex-grow:1;flex-shrink:1}.navbar-link:not(.is-arrowless){padding-right:2.5em}.navbar-link:not(.is-arrowless)::after{border-color:#3273dc;margin-top:-.375em;right:1.125em}.navbar-dropdown{font-size:.875rem;padding-bottom:.5rem;padding-top:.5rem}.navbar-dropdown .navbar-item{padding-left:1.5rem;padding-right:1.5rem}.navbar-divider{background-color:#f5f5f5;border:none;display:none;height:2px;margin:.5rem 0}@media screen and (max-width:1023px){.navbar>.container{display:block}.navbar-brand .navbar-item,.navbar-tabs .navbar-item{align-items:center;display:flex}.navbar-link::after{display:none}.navbar-menu{background-color:#fff;box-shadow:0 8px 16px rgba(10,10,10,.1);padding:.5rem 0}.navbar-menu.is-active{display:block}.navbar.is-fixed-bottom-touch,.navbar.is-fixed-top-touch{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-touch{bottom:0}.navbar.is-fixed-bottom-touch.has-shadow{box-shadow:0 -2px 3px rgba(10,10,10,.1)}.navbar.is-fixed-top-touch{top:0}.navbar.is-fixed-top .navbar-menu,.navbar.is-fixed-top-touch .navbar-menu{-webkit-overflow-scrolling:touch;max-height:calc(100vh - 3.25rem);overflow:auto}body.has-navbar-fixed-top-touch,html.has-navbar-fixed-top-touch{padding-top:3.25rem}body.has-navbar-fixed-bottom-touch,html.has-navbar-fixed-bottom-touch{padding-bottom:3.25rem}}@media screen and (min-width:1024px){.navbar,.navbar-end,.navbar-menu,.navbar-start{align-items:stretch;display:flex}.navbar{min-height:3.25rem}.navbar.is-spaced{padding:1rem 2rem}.navbar.is-spaced .navbar-end,.navbar.is-spaced .navbar-start{align-items:center}.navbar.is-spaced .navbar-link,.navbar.is-spaced a.navbar-item{border-radius:4px}.navbar.is-transparent .navbar-link.is-active,.navbar.is-transparent .navbar-link:focus,.navbar.is-transparent .navbar-link:hover,.navbar.is-transparent a.navbar-item.is-active,.navbar.is-transparent a.navbar-item:focus,.navbar.is-transparent a.navbar-item:hover{background-color:transparent!important}.navbar.is-transparent .navbar-item.has-dropdown.is-active .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:focus-within .navbar-link,.navbar.is-transparent .navbar-item.has-dropdown.is-hoverable:hover .navbar-link{background-color:transparent!important}.navbar.is-transparent .navbar-dropdown a.navbar-item:focus,.navbar.is-transparent .navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar.is-transparent .navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#3273dc}.navbar-burger{display:none}.navbar-item,.navbar-link{align-items:center;display:flex}.navbar-item.has-dropdown{align-items:stretch}.navbar-item.has-dropdown-up .navbar-link::after{transform:rotate(135deg) translate(.25em,-.25em)}.navbar-item.has-dropdown-up .navbar-dropdown{border-bottom:2px solid #dbdbdb;border-radius:6px 6px 0 0;border-top:none;bottom:100%;box-shadow:0 -8px 8px rgba(10,10,10,.1);top:auto}.navbar-item.is-active .navbar-dropdown,.navbar-item.is-hoverable:focus .navbar-dropdown,.navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar-item.is-hoverable:hover .navbar-dropdown{display:block}.navbar-item.is-active .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:focus .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:focus-within .navbar-dropdown.is-boxed,.navbar-item.is-hoverable:hover .navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-item.is-active .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:focus .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:focus-within .navbar-dropdown,.navbar.is-spaced .navbar-item.is-hoverable:hover .navbar-dropdown{opacity:1;pointer-events:auto;transform:translateY(0)}.navbar-menu{flex-grow:1;flex-shrink:0}.navbar-start{justify-content:flex-start;margin-right:auto}.navbar-end{justify-content:flex-end;margin-left:auto}.navbar-dropdown{background-color:#fff;border-bottom-left-radius:6px;border-bottom-right-radius:6px;border-top:2px solid #dbdbdb;box-shadow:0 8px 8px rgba(10,10,10,.1);display:none;font-size:.875rem;left:0;min-width:100%;position:absolute;top:100%;z-index:20}.navbar-dropdown .navbar-item{padding:.375rem 1rem;white-space:nowrap}.navbar-dropdown a.navbar-item{padding-right:3rem}.navbar-dropdown a.navbar-item:focus,.navbar-dropdown a.navbar-item:hover{background-color:#f5f5f5;color:#0a0a0a}.navbar-dropdown a.navbar-item.is-active{background-color:#f5f5f5;color:#3273dc}.navbar-dropdown.is-boxed,.navbar.is-spaced .navbar-dropdown{border-radius:6px;border-top:none;box-shadow:0 8px 8px rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.1);display:block;opacity:0;pointer-events:none;top:calc(100% + (-4px));transform:translateY(-5px);transition-duration:86ms;transition-property:opacity,transform}.navbar-dropdown.is-right{left:auto;right:0}.navbar-divider{display:block}.container>.navbar .navbar-brand,.navbar>.container .navbar-brand{margin-left:-.75rem}.container>.navbar .navbar-menu,.navbar>.container .navbar-menu{margin-right:-.75rem}.navbar.is-fixed-bottom-desktop,.navbar.is-fixed-top-desktop{left:0;position:fixed;right:0;z-index:30}.navbar.is-fixed-bottom-desktop{bottom:0}.navbar.is-fixed-bottom-desktop.has-shadow{box-shadow:0 -2px 3px rgba(10,10,10,.1)}.navbar.is-fixed-top-desktop{top:0}body.has-navbar-fixed-top-desktop,html.has-navbar-fixed-top-desktop{padding-top:3.25rem}body.has-navbar-fixed-bottom-desktop,html.has-navbar-fixed-bottom-desktop{padding-bottom:3.25rem}body.has-spaced-navbar-fixed-top,html.has-spaced-navbar-fixed-top{padding-top:5.25rem}body.has-spaced-navbar-fixed-bottom,html.has-spaced-navbar-fixed-bottom{padding-bottom:5.25rem}.navbar-link.is-active,a.navbar-item.is-active{color:#0a0a0a}.navbar-link.is-active:not(:focus):not(:hover),a.navbar-item.is-active:not(:focus):not(:hover){background-color:transparent}.navbar-item.has-dropdown.is-active .navbar-link,.navbar-item.has-dropdown:focus .navbar-link,.navbar-item.has-dropdown:hover .navbar-link{background-color:#fafafa}}.hero.is-fullheight-with-navbar{min-height:calc(100vh - 3.25rem)}.pagination{font-size:1rem;margin:-.25rem}.pagination.is-small{font-size:.75rem}.pagination.is-medium{font-size:1.25rem}.pagination.is-large{font-size:1.5rem}.pagination.is-rounded .pagination-next,.pagination.is-rounded .pagination-previous{padding-left:1em;padding-right:1em;border-radius:290486px}.pagination.is-rounded .pagination-link{border-radius:290486px}.pagination,.pagination-list{align-items:center;display:flex;justify-content:center;text-align:center}.pagination-ellipsis,.pagination-link,.pagination-next,.pagination-previous{font-size:1em;justify-content:center;margin:.25rem;padding-left:.5em;padding-right:.5em;text-align:center}.pagination-link,.pagination-next,.pagination-previous{border-color:#dbdbdb;color:#363636;min-width:2.5em}.pagination-link:hover,.pagination-next:hover,.pagination-previous:hover{border-color:#b5b5b5;color:#363636}.pagination-link:focus,.pagination-next:focus,.pagination-previous:focus{border-color:#3273dc}.pagination-link:active,.pagination-next:active,.pagination-previous:active{box-shadow:inset 0 1px 2px rgba(10,10,10,.2)}.pagination-link[disabled],.pagination-next[disabled],.pagination-previous[disabled]{background-color:#dbdbdb;border-color:#dbdbdb;box-shadow:none;color:#7a7a7a;opacity:.5}.pagination-next,.pagination-previous{padding-left:.75em;padding-right:.75em;white-space:nowrap}.pagination-link.is-current{background-color:#3273dc;border-color:#3273dc;color:#fff}.pagination-ellipsis{color:#b5b5b5;pointer-events:none}.pagination-list{flex-wrap:wrap}.pagination-list li{list-style:none}@media screen and (max-width:768px){.pagination{flex-wrap:wrap}.pagination-next,.pagination-previous{flex-grow:1;flex-shrink:1}.pagination-list li{flex-grow:1;flex-shrink:1}}@media screen and (min-width:769px),print{.pagination-list{flex-grow:1;flex-shrink:1;justify-content:flex-start;order:1}.pagination-previous{order:2}.pagination-next{order:3}.pagination{justify-content:space-between}.pagination.is-centered .pagination-previous{order:1}.pagination.is-centered .pagination-list{justify-content:center;order:2}.pagination.is-centered .pagination-next{order:3}.pagination.is-right .pagination-previous{order:1}.pagination.is-right .pagination-next{order:2}.pagination.is-right .pagination-list{justify-content:flex-end;order:3}}.panel{border-radius:6px;box-shadow:0 .5em 1em -.125em rgba(10,10,10,.1),0 0 0 1px rgba(10,10,10,.02);font-size:1rem}.panel:not(:last-child){margin-bottom:1.5rem}.panel.is-white .panel-heading{background-color:#fff;color:#0a0a0a}.panel.is-white .panel-tabs a.is-active{border-bottom-color:#fff}.panel.is-white .panel-block.is-active .panel-icon{color:#fff}.panel.is-black .panel-heading{background-color:#0a0a0a;color:#fff}.panel.is-black .panel-tabs a.is-active{border-bottom-color:#0a0a0a}.panel.is-black .panel-block.is-active .panel-icon{color:#0a0a0a}.panel.is-light .panel-heading{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.panel.is-light .panel-tabs a.is-active{border-bottom-color:#f5f5f5}.panel.is-light .panel-block.is-active .panel-icon{color:#f5f5f5}.panel.is-dark .panel-heading{background-color:#363636;color:#fff}.panel.is-dark .panel-tabs a.is-active{border-bottom-color:#363636}.panel.is-dark .panel-block.is-active .panel-icon{color:#363636}.panel.is-primary .panel-heading{background-color:#00d1b2;color:#fff}.panel.is-primary .panel-tabs a.is-active{border-bottom-color:#00d1b2}.panel.is-primary .panel-block.is-active .panel-icon{color:#00d1b2}.panel.is-link .panel-heading{background-color:#3273dc;color:#fff}.panel.is-link .panel-tabs a.is-active{border-bottom-color:#3273dc}.panel.is-link .panel-block.is-active .panel-icon{color:#3273dc}.panel.is-info .panel-heading{background-color:#3298dc;color:#fff}.panel.is-info .panel-tabs a.is-active{border-bottom-color:#3298dc}.panel.is-info .panel-block.is-active .panel-icon{color:#3298dc}.panel.is-success .panel-heading{background-color:#48c774;color:#fff}.panel.is-success .panel-tabs a.is-active{border-bottom-color:#48c774}.panel.is-success .panel-block.is-active .panel-icon{color:#48c774}.panel.is-warning .panel-heading{background-color:#ffdd57;color:rgba(0,0,0,.7)}.panel.is-warning .panel-tabs a.is-active{border-bottom-color:#ffdd57}.panel.is-warning .panel-block.is-active .panel-icon{color:#ffdd57}.panel.is-danger .panel-heading{background-color:#f14668;color:#fff}.panel.is-danger .panel-tabs a.is-active{border-bottom-color:#f14668}.panel.is-danger .panel-block.is-active .panel-icon{color:#f14668}.panel-block:not(:last-child),.panel-tabs:not(:last-child){border-bottom:1px solid #ededed}.panel-heading{background-color:#ededed;border-radius:6px 6px 0 0;color:#363636;font-size:1.25em;font-weight:700;line-height:1.25;padding:.75em 1em}.panel-tabs{align-items:flex-end;display:flex;font-size:.875em;justify-content:center}.panel-tabs a{border-bottom:1px solid #dbdbdb;margin-bottom:-1px;padding:.5em}.panel-tabs a.is-active{border-bottom-color:#4a4a4a;color:#363636}.panel-list a{color:#4a4a4a}.panel-list a:hover{color:#3273dc}.panel-block{align-items:center;color:#363636;display:flex;justify-content:flex-start;padding:.5em .75em}.panel-block input[type=checkbox]{margin-right:.75em}.panel-block>.control{flex-grow:1;flex-shrink:1;width:100%}.panel-block.is-wrapped{flex-wrap:wrap}.panel-block.is-active{border-left-color:#3273dc;color:#363636}.panel-block.is-active .panel-icon{color:#3273dc}.panel-block:last-child{border-bottom-left-radius:6px;border-bottom-right-radius:6px}a.panel-block,label.panel-block{cursor:pointer}a.panel-block:hover,label.panel-block:hover{background-color:#f5f5f5}.panel-icon{display:inline-block;font-size:14px;height:1em;line-height:1em;text-align:center;vertical-align:top;width:1em;color:#7a7a7a;margin-right:.75em}.panel-icon .fa{font-size:inherit;line-height:inherit}.tabs{-webkit-overflow-scrolling:touch;align-items:stretch;display:flex;font-size:1rem;justify-content:space-between;overflow:hidden;overflow-x:auto;white-space:nowrap}.tabs a{align-items:center;border-bottom-color:#dbdbdb;border-bottom-style:solid;border-bottom-width:1px;color:#4a4a4a;display:flex;justify-content:center;margin-bottom:-1px;padding:.5em 1em;vertical-align:top}.tabs a:hover{border-bottom-color:#363636;color:#363636}.tabs li{display:block}.tabs li.is-active a{border-bottom-color:#3273dc;color:#3273dc}.tabs ul{align-items:center;border-bottom-color:#dbdbdb;border-bottom-style:solid;border-bottom-width:1px;display:flex;flex-grow:1;flex-shrink:0;justify-content:flex-start}.tabs ul.is-left{padding-right:.75em}.tabs ul.is-center{flex:none;justify-content:center;padding-left:.75em;padding-right:.75em}.tabs ul.is-right{justify-content:flex-end;padding-left:.75em}.tabs .icon:first-child{margin-right:.5em}.tabs .icon:last-child{margin-left:.5em}.tabs.is-centered ul{justify-content:center}.tabs.is-right ul{justify-content:flex-end}.tabs.is-boxed a{border:1px solid transparent;border-radius:4px 4px 0 0}.tabs.is-boxed a:hover{background-color:#f5f5f5;border-bottom-color:#dbdbdb}.tabs.is-boxed li.is-active a{background-color:#fff;border-color:#dbdbdb;border-bottom-color:transparent!important}.tabs.is-fullwidth li{flex-grow:1;flex-shrink:0}.tabs.is-toggle a{border-color:#dbdbdb;border-style:solid;border-width:1px;margin-bottom:0;position:relative}.tabs.is-toggle a:hover{background-color:#f5f5f5;border-color:#b5b5b5;z-index:2}.tabs.is-toggle li+li{margin-left:-1px}.tabs.is-toggle li:first-child a{border-top-left-radius:4px;border-bottom-left-radius:4px}.tabs.is-toggle li:last-child a{border-top-right-radius:4px;border-bottom-right-radius:4px}.tabs.is-toggle li.is-active a{background-color:#3273dc;border-color:#3273dc;color:#fff;z-index:1}.tabs.is-toggle ul{border-bottom:none}.tabs.is-toggle.is-toggle-rounded li:first-child a{border-bottom-left-radius:290486px;border-top-left-radius:290486px;padding-left:1.25em}.tabs.is-toggle.is-toggle-rounded li:last-child a{border-bottom-right-radius:290486px;border-top-right-radius:290486px;padding-right:1.25em}.tabs.is-small{font-size:.75rem}.tabs.is-medium{font-size:1.25rem}.tabs.is-large{font-size:1.5rem}.column{display:block;flex-basis:0;flex-grow:1;flex-shrink:1;padding:.75rem}.columns.is-mobile>.column.is-narrow{flex:none;width:unset}.columns.is-mobile>.column.is-full{flex:none;width:100%}.columns.is-mobile>.column.is-three-quarters{flex:none;width:75%}.columns.is-mobile>.column.is-two-thirds{flex:none;width:66.6666%}.columns.is-mobile>.column.is-half{flex:none;width:50%}.columns.is-mobile>.column.is-one-third{flex:none;width:33.3333%}.columns.is-mobile>.column.is-one-quarter{flex:none;width:25%}.columns.is-mobile>.column.is-one-fifth{flex:none;width:20%}.columns.is-mobile>.column.is-two-fifths{flex:none;width:40%}.columns.is-mobile>.column.is-three-fifths{flex:none;width:60%}.columns.is-mobile>.column.is-four-fifths{flex:none;width:80%}.columns.is-mobile>.column.is-offset-three-quarters{margin-left:75%}.columns.is-mobile>.column.is-offset-two-thirds{margin-left:66.6666%}.columns.is-mobile>.column.is-offset-half{margin-left:50%}.columns.is-mobile>.column.is-offset-one-third{margin-left:33.3333%}.columns.is-mobile>.column.is-offset-one-quarter{margin-left:25%}.columns.is-mobile>.column.is-offset-one-fifth{margin-left:20%}.columns.is-mobile>.column.is-offset-two-fifths{margin-left:40%}.columns.is-mobile>.column.is-offset-three-fifths{margin-left:60%}.columns.is-mobile>.column.is-offset-four-fifths{margin-left:80%}.columns.is-mobile>.column.is-0{flex:none;width:0%}.columns.is-mobile>.column.is-offset-0{margin-left:0}.columns.is-mobile>.column.is-1{flex:none;width:8.33333%}.columns.is-mobile>.column.is-offset-1{margin-left:8.33333%}.columns.is-mobile>.column.is-2{flex:none;width:16.66667%}.columns.is-mobile>.column.is-offset-2{margin-left:16.66667%}.columns.is-mobile>.column.is-3{flex:none;width:25%}.columns.is-mobile>.column.is-offset-3{margin-left:25%}.columns.is-mobile>.column.is-4{flex:none;width:33.33333%}.columns.is-mobile>.column.is-offset-4{margin-left:33.33333%}.columns.is-mobile>.column.is-5{flex:none;width:41.66667%}.columns.is-mobile>.column.is-offset-5{margin-left:41.66667%}.columns.is-mobile>.column.is-6{flex:none;width:50%}.columns.is-mobile>.column.is-offset-6{margin-left:50%}.columns.is-mobile>.column.is-7{flex:none;width:58.33333%}.columns.is-mobile>.column.is-offset-7{margin-left:58.33333%}.columns.is-mobile>.column.is-8{flex:none;width:66.66667%}.columns.is-mobile>.column.is-offset-8{margin-left:66.66667%}.columns.is-mobile>.column.is-9{flex:none;width:75%}.columns.is-mobile>.column.is-offset-9{margin-left:75%}.columns.is-mobile>.column.is-10{flex:none;width:83.33333%}.columns.is-mobile>.column.is-offset-10{margin-left:83.33333%}.columns.is-mobile>.column.is-11{flex:none;width:91.66667%}.columns.is-mobile>.column.is-offset-11{margin-left:91.66667%}.columns.is-mobile>.column.is-12{flex:none;width:100%}.columns.is-mobile>.column.is-offset-12{margin-left:100%}@media screen and (max-width:768px){.column.is-narrow-mobile{flex:none;width:unset}.column.is-full-mobile{flex:none;width:100%}.column.is-three-quarters-mobile{flex:none;width:75%}.column.is-two-thirds-mobile{flex:none;width:66.6666%}.column.is-half-mobile{flex:none;width:50%}.column.is-one-third-mobile{flex:none;width:33.3333%}.column.is-one-quarter-mobile{flex:none;width:25%}.column.is-one-fifth-mobile{flex:none;width:20%}.column.is-two-fifths-mobile{flex:none;width:40%}.column.is-three-fifths-mobile{flex:none;width:60%}.column.is-four-fifths-mobile{flex:none;width:80%}.column.is-offset-three-quarters-mobile{margin-left:75%}.column.is-offset-two-thirds-mobile{margin-left:66.6666%}.column.is-offset-half-mobile{margin-left:50%}.column.is-offset-one-third-mobile{margin-left:33.3333%}.column.is-offset-one-quarter-mobile{margin-left:25%}.column.is-offset-one-fifth-mobile{margin-left:20%}.column.is-offset-two-fifths-mobile{margin-left:40%}.column.is-offset-three-fifths-mobile{margin-left:60%}.column.is-offset-four-fifths-mobile{margin-left:80%}.column.is-0-mobile{flex:none;width:0%}.column.is-offset-0-mobile{margin-left:0}.column.is-1-mobile{flex:none;width:8.33333%}.column.is-offset-1-mobile{margin-left:8.33333%}.column.is-2-mobile{flex:none;width:16.66667%}.column.is-offset-2-mobile{margin-left:16.66667%}.column.is-3-mobile{flex:none;width:25%}.column.is-offset-3-mobile{margin-left:25%}.column.is-4-mobile{flex:none;width:33.33333%}.column.is-offset-4-mobile{margin-left:33.33333%}.column.is-5-mobile{flex:none;width:41.66667%}.column.is-offset-5-mobile{margin-left:41.66667%}.column.is-6-mobile{flex:none;width:50%}.column.is-offset-6-mobile{margin-left:50%}.column.is-7-mobile{flex:none;width:58.33333%}.column.is-offset-7-mobile{margin-left:58.33333%}.column.is-8-mobile{flex:none;width:66.66667%}.column.is-offset-8-mobile{margin-left:66.66667%}.column.is-9-mobile{flex:none;width:75%}.column.is-offset-9-mobile{margin-left:75%}.column.is-10-mobile{flex:none;width:83.33333%}.column.is-offset-10-mobile{margin-left:83.33333%}.column.is-11-mobile{flex:none;width:91.66667%}.column.is-offset-11-mobile{margin-left:91.66667%}.column.is-12-mobile{flex:none;width:100%}.column.is-offset-12-mobile{margin-left:100%}}@media screen and (min-width:769px),print{.column.is-narrow,.column.is-narrow-tablet{flex:none;width:unset}.column.is-full,.column.is-full-tablet{flex:none;width:100%}.column.is-three-quarters,.column.is-three-quarters-tablet{flex:none;width:75%}.column.is-two-thirds,.column.is-two-thirds-tablet{flex:none;width:66.6666%}.column.is-half,.column.is-half-tablet{flex:none;width:50%}.column.is-one-third,.column.is-one-third-tablet{flex:none;width:33.3333%}.column.is-one-quarter,.column.is-one-quarter-tablet{flex:none;width:25%}.column.is-one-fifth,.column.is-one-fifth-tablet{flex:none;width:20%}.column.is-two-fifths,.column.is-two-fifths-tablet{flex:none;width:40%}.column.is-three-fifths,.column.is-three-fifths-tablet{flex:none;width:60%}.column.is-four-fifths,.column.is-four-fifths-tablet{flex:none;width:80%}.column.is-offset-three-quarters,.column.is-offset-three-quarters-tablet{margin-left:75%}.column.is-offset-two-thirds,.column.is-offset-two-thirds-tablet{margin-left:66.6666%}.column.is-offset-half,.column.is-offset-half-tablet{margin-left:50%}.column.is-offset-one-third,.column.is-offset-one-third-tablet{margin-left:33.3333%}.column.is-offset-one-quarter,.column.is-offset-one-quarter-tablet{margin-left:25%}.column.is-offset-one-fifth,.column.is-offset-one-fifth-tablet{margin-left:20%}.column.is-offset-two-fifths,.column.is-offset-two-fifths-tablet{margin-left:40%}.column.is-offset-three-fifths,.column.is-offset-three-fifths-tablet{margin-left:60%}.column.is-offset-four-fifths,.column.is-offset-four-fifths-tablet{margin-left:80%}.column.is-0,.column.is-0-tablet{flex:none;width:0%}.column.is-offset-0,.column.is-offset-0-tablet{margin-left:0}.column.is-1,.column.is-1-tablet{flex:none;width:8.33333%}.column.is-offset-1,.column.is-offset-1-tablet{margin-left:8.33333%}.column.is-2,.column.is-2-tablet{flex:none;width:16.66667%}.column.is-offset-2,.column.is-offset-2-tablet{margin-left:16.66667%}.column.is-3,.column.is-3-tablet{flex:none;width:25%}.column.is-offset-3,.column.is-offset-3-tablet{margin-left:25%}.column.is-4,.column.is-4-tablet{flex:none;width:33.33333%}.column.is-offset-4,.column.is-offset-4-tablet{margin-left:33.33333%}.column.is-5,.column.is-5-tablet{flex:none;width:41.66667%}.column.is-offset-5,.column.is-offset-5-tablet{margin-left:41.66667%}.column.is-6,.column.is-6-tablet{flex:none;width:50%}.column.is-offset-6,.column.is-offset-6-tablet{margin-left:50%}.column.is-7,.column.is-7-tablet{flex:none;width:58.33333%}.column.is-offset-7,.column.is-offset-7-tablet{margin-left:58.33333%}.column.is-8,.column.is-8-tablet{flex:none;width:66.66667%}.column.is-offset-8,.column.is-offset-8-tablet{margin-left:66.66667%}.column.is-9,.column.is-9-tablet{flex:none;width:75%}.column.is-offset-9,.column.is-offset-9-tablet{margin-left:75%}.column.is-10,.column.is-10-tablet{flex:none;width:83.33333%}.column.is-offset-10,.column.is-offset-10-tablet{margin-left:83.33333%}.column.is-11,.column.is-11-tablet{flex:none;width:91.66667%}.column.is-offset-11,.column.is-offset-11-tablet{margin-left:91.66667%}.column.is-12,.column.is-12-tablet{flex:none;width:100%}.column.is-offset-12,.column.is-offset-12-tablet{margin-left:100%}}@media screen and (max-width:1023px){.column.is-narrow-touch{flex:none;width:unset}.column.is-full-touch{flex:none;width:100%}.column.is-three-quarters-touch{flex:none;width:75%}.column.is-two-thirds-touch{flex:none;width:66.6666%}.column.is-half-touch{flex:none;width:50%}.column.is-one-third-touch{flex:none;width:33.3333%}.column.is-one-quarter-touch{flex:none;width:25%}.column.is-one-fifth-touch{flex:none;width:20%}.column.is-two-fifths-touch{flex:none;width:40%}.column.is-three-fifths-touch{flex:none;width:60%}.column.is-four-fifths-touch{flex:none;width:80%}.column.is-offset-three-quarters-touch{margin-left:75%}.column.is-offset-two-thirds-touch{margin-left:66.6666%}.column.is-offset-half-touch{margin-left:50%}.column.is-offset-one-third-touch{margin-left:33.3333%}.column.is-offset-one-quarter-touch{margin-left:25%}.column.is-offset-one-fifth-touch{margin-left:20%}.column.is-offset-two-fifths-touch{margin-left:40%}.column.is-offset-three-fifths-touch{margin-left:60%}.column.is-offset-four-fifths-touch{margin-left:80%}.column.is-0-touch{flex:none;width:0%}.column.is-offset-0-touch{margin-left:0}.column.is-1-touch{flex:none;width:8.33333%}.column.is-offset-1-touch{margin-left:8.33333%}.column.is-2-touch{flex:none;width:16.66667%}.column.is-offset-2-touch{margin-left:16.66667%}.column.is-3-touch{flex:none;width:25%}.column.is-offset-3-touch{margin-left:25%}.column.is-4-touch{flex:none;width:33.33333%}.column.is-offset-4-touch{margin-left:33.33333%}.column.is-5-touch{flex:none;width:41.66667%}.column.is-offset-5-touch{margin-left:41.66667%}.column.is-6-touch{flex:none;width:50%}.column.is-offset-6-touch{margin-left:50%}.column.is-7-touch{flex:none;width:58.33333%}.column.is-offset-7-touch{margin-left:58.33333%}.column.is-8-touch{flex:none;width:66.66667%}.column.is-offset-8-touch{margin-left:66.66667%}.column.is-9-touch{flex:none;width:75%}.column.is-offset-9-touch{margin-left:75%}.column.is-10-touch{flex:none;width:83.33333%}.column.is-offset-10-touch{margin-left:83.33333%}.column.is-11-touch{flex:none;width:91.66667%}.column.is-offset-11-touch{margin-left:91.66667%}.column.is-12-touch{flex:none;width:100%}.column.is-offset-12-touch{margin-left:100%}}@media screen and (min-width:1024px){.column.is-narrow-desktop{flex:none;width:unset}.column.is-full-desktop{flex:none;width:100%}.column.is-three-quarters-desktop{flex:none;width:75%}.column.is-two-thirds-desktop{flex:none;width:66.6666%}.column.is-half-desktop{flex:none;width:50%}.column.is-one-third-desktop{flex:none;width:33.3333%}.column.is-one-quarter-desktop{flex:none;width:25%}.column.is-one-fifth-desktop{flex:none;width:20%}.column.is-two-fifths-desktop{flex:none;width:40%}.column.is-three-fifths-desktop{flex:none;width:60%}.column.is-four-fifths-desktop{flex:none;width:80%}.column.is-offset-three-quarters-desktop{margin-left:75%}.column.is-offset-two-thirds-desktop{margin-left:66.6666%}.column.is-offset-half-desktop{margin-left:50%}.column.is-offset-one-third-desktop{margin-left:33.3333%}.column.is-offset-one-quarter-desktop{margin-left:25%}.column.is-offset-one-fifth-desktop{margin-left:20%}.column.is-offset-two-fifths-desktop{margin-left:40%}.column.is-offset-three-fifths-desktop{margin-left:60%}.column.is-offset-four-fifths-desktop{margin-left:80%}.column.is-0-desktop{flex:none;width:0%}.column.is-offset-0-desktop{margin-left:0}.column.is-1-desktop{flex:none;width:8.33333%}.column.is-offset-1-desktop{margin-left:8.33333%}.column.is-2-desktop{flex:none;width:16.66667%}.column.is-offset-2-desktop{margin-left:16.66667%}.column.is-3-desktop{flex:none;width:25%}.column.is-offset-3-desktop{margin-left:25%}.column.is-4-desktop{flex:none;width:33.33333%}.column.is-offset-4-desktop{margin-left:33.33333%}.column.is-5-desktop{flex:none;width:41.66667%}.column.is-offset-5-desktop{margin-left:41.66667%}.column.is-6-desktop{flex:none;width:50%}.column.is-offset-6-desktop{margin-left:50%}.column.is-7-desktop{flex:none;width:58.33333%}.column.is-offset-7-desktop{margin-left:58.33333%}.column.is-8-desktop{flex:none;width:66.66667%}.column.is-offset-8-desktop{margin-left:66.66667%}.column.is-9-desktop{flex:none;width:75%}.column.is-offset-9-desktop{margin-left:75%}.column.is-10-desktop{flex:none;width:83.33333%}.column.is-offset-10-desktop{margin-left:83.33333%}.column.is-11-desktop{flex:none;width:91.66667%}.column.is-offset-11-desktop{margin-left:91.66667%}.column.is-12-desktop{flex:none;width:100%}.column.is-offset-12-desktop{margin-left:100%}}@media screen and (min-width:1216px){.column.is-narrow-widescreen{flex:none;width:unset}.column.is-full-widescreen{flex:none;width:100%}.column.is-three-quarters-widescreen{flex:none;width:75%}.column.is-two-thirds-widescreen{flex:none;width:66.6666%}.column.is-half-widescreen{flex:none;width:50%}.column.is-one-third-widescreen{flex:none;width:33.3333%}.column.is-one-quarter-widescreen{flex:none;width:25%}.column.is-one-fifth-widescreen{flex:none;width:20%}.column.is-two-fifths-widescreen{flex:none;width:40%}.column.is-three-fifths-widescreen{flex:none;width:60%}.column.is-four-fifths-widescreen{flex:none;width:80%}.column.is-offset-three-quarters-widescreen{margin-left:75%}.column.is-offset-two-thirds-widescreen{margin-left:66.6666%}.column.is-offset-half-widescreen{margin-left:50%}.column.is-offset-one-third-widescreen{margin-left:33.3333%}.column.is-offset-one-quarter-widescreen{margin-left:25%}.column.is-offset-one-fifth-widescreen{margin-left:20%}.column.is-offset-two-fifths-widescreen{margin-left:40%}.column.is-offset-three-fifths-widescreen{margin-left:60%}.column.is-offset-four-fifths-widescreen{margin-left:80%}.column.is-0-widescreen{flex:none;width:0%}.column.is-offset-0-widescreen{margin-left:0}.column.is-1-widescreen{flex:none;width:8.33333%}.column.is-offset-1-widescreen{margin-left:8.33333%}.column.is-2-widescreen{flex:none;width:16.66667%}.column.is-offset-2-widescreen{margin-left:16.66667%}.column.is-3-widescreen{flex:none;width:25%}.column.is-offset-3-widescreen{margin-left:25%}.column.is-4-widescreen{flex:none;width:33.33333%}.column.is-offset-4-widescreen{margin-left:33.33333%}.column.is-5-widescreen{flex:none;width:41.66667%}.column.is-offset-5-widescreen{margin-left:41.66667%}.column.is-6-widescreen{flex:none;width:50%}.column.is-offset-6-widescreen{margin-left:50%}.column.is-7-widescreen{flex:none;width:58.33333%}.column.is-offset-7-widescreen{margin-left:58.33333%}.column.is-8-widescreen{flex:none;width:66.66667%}.column.is-offset-8-widescreen{margin-left:66.66667%}.column.is-9-widescreen{flex:none;width:75%}.column.is-offset-9-widescreen{margin-left:75%}.column.is-10-widescreen{flex:none;width:83.33333%}.column.is-offset-10-widescreen{margin-left:83.33333%}.column.is-11-widescreen{flex:none;width:91.66667%}.column.is-offset-11-widescreen{margin-left:91.66667%}.column.is-12-widescreen{flex:none;width:100%}.column.is-offset-12-widescreen{margin-left:100%}}@media screen and (min-width:1408px){.column.is-narrow-fullhd{flex:none;width:unset}.column.is-full-fullhd{flex:none;width:100%}.column.is-three-quarters-fullhd{flex:none;width:75%}.column.is-two-thirds-fullhd{flex:none;width:66.6666%}.column.is-half-fullhd{flex:none;width:50%}.column.is-one-third-fullhd{flex:none;width:33.3333%}.column.is-one-quarter-fullhd{flex:none;width:25%}.column.is-one-fifth-fullhd{flex:none;width:20%}.column.is-two-fifths-fullhd{flex:none;width:40%}.column.is-three-fifths-fullhd{flex:none;width:60%}.column.is-four-fifths-fullhd{flex:none;width:80%}.column.is-offset-three-quarters-fullhd{margin-left:75%}.column.is-offset-two-thirds-fullhd{margin-left:66.6666%}.column.is-offset-half-fullhd{margin-left:50%}.column.is-offset-one-third-fullhd{margin-left:33.3333%}.column.is-offset-one-quarter-fullhd{margin-left:25%}.column.is-offset-one-fifth-fullhd{margin-left:20%}.column.is-offset-two-fifths-fullhd{margin-left:40%}.column.is-offset-three-fifths-fullhd{margin-left:60%}.column.is-offset-four-fifths-fullhd{margin-left:80%}.column.is-0-fullhd{flex:none;width:0%}.column.is-offset-0-fullhd{margin-left:0}.column.is-1-fullhd{flex:none;width:8.33333%}.column.is-offset-1-fullhd{margin-left:8.33333%}.column.is-2-fullhd{flex:none;width:16.66667%}.column.is-offset-2-fullhd{margin-left:16.66667%}.column.is-3-fullhd{flex:none;width:25%}.column.is-offset-3-fullhd{margin-left:25%}.column.is-4-fullhd{flex:none;width:33.33333%}.column.is-offset-4-fullhd{margin-left:33.33333%}.column.is-5-fullhd{flex:none;width:41.66667%}.column.is-offset-5-fullhd{margin-left:41.66667%}.column.is-6-fullhd{flex:none;width:50%}.column.is-offset-6-fullhd{margin-left:50%}.column.is-7-fullhd{flex:none;width:58.33333%}.column.is-offset-7-fullhd{margin-left:58.33333%}.column.is-8-fullhd{flex:none;width:66.66667%}.column.is-offset-8-fullhd{margin-left:66.66667%}.column.is-9-fullhd{flex:none;width:75%}.column.is-offset-9-fullhd{margin-left:75%}.column.is-10-fullhd{flex:none;width:83.33333%}.column.is-offset-10-fullhd{margin-left:83.33333%}.column.is-11-fullhd{flex:none;width:91.66667%}.column.is-offset-11-fullhd{margin-left:91.66667%}.column.is-12-fullhd{flex:none;width:100%}.column.is-offset-12-fullhd{margin-left:100%}}.columns{margin-left:-.75rem;margin-right:-.75rem;margin-top:-.75rem}.columns:last-child{margin-bottom:-.75rem}.columns:not(:last-child){margin-bottom:calc(1.5rem - .75rem)}.columns.is-centered{justify-content:center}.columns.is-gapless{margin-left:0;margin-right:0;margin-top:0}.columns.is-gapless>.column{margin:0;padding:0!important}.columns.is-gapless:not(:last-child){margin-bottom:1.5rem}.columns.is-gapless:last-child{margin-bottom:0}.columns.is-mobile{display:flex}.columns.is-multiline{flex-wrap:wrap}.columns.is-vcentered{align-items:center}@media screen and (min-width:769px),print{.columns:not(.is-desktop){display:flex}}@media screen and (min-width:1024px){.columns.is-desktop{display:flex}}.columns.is-variable{--columnGap:0.75rem;margin-left:calc(-1 * var(--columnGap));margin-right:calc(-1 * var(--columnGap))}.columns.is-variable>.column{padding-left:var(--columnGap);padding-right:var(--columnGap)}.columns.is-variable.is-0{--columnGap:0rem}@media screen and (max-width:768px){.columns.is-variable.is-0-mobile{--columnGap:0rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-0-tablet{--columnGap:0rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-0-tablet-only{--columnGap:0rem}}@media screen and (max-width:1023px){.columns.is-variable.is-0-touch{--columnGap:0rem}}@media screen and (min-width:1024px){.columns.is-variable.is-0-desktop{--columnGap:0rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-0-desktop-only{--columnGap:0rem}}@media screen and (min-width:1216px){.columns.is-variable.is-0-widescreen{--columnGap:0rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-0-widescreen-only{--columnGap:0rem}}@media screen and (min-width:1408px){.columns.is-variable.is-0-fullhd{--columnGap:0rem}}.columns.is-variable.is-1{--columnGap:0.25rem}@media screen and (max-width:768px){.columns.is-variable.is-1-mobile{--columnGap:0.25rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-1-tablet{--columnGap:0.25rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-1-tablet-only{--columnGap:0.25rem}}@media screen and (max-width:1023px){.columns.is-variable.is-1-touch{--columnGap:0.25rem}}@media screen and (min-width:1024px){.columns.is-variable.is-1-desktop{--columnGap:0.25rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-1-desktop-only{--columnGap:0.25rem}}@media screen and (min-width:1216px){.columns.is-variable.is-1-widescreen{--columnGap:0.25rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-1-widescreen-only{--columnGap:0.25rem}}@media screen and (min-width:1408px){.columns.is-variable.is-1-fullhd{--columnGap:0.25rem}}.columns.is-variable.is-2{--columnGap:0.5rem}@media screen and (max-width:768px){.columns.is-variable.is-2-mobile{--columnGap:0.5rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-2-tablet{--columnGap:0.5rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-2-tablet-only{--columnGap:0.5rem}}@media screen and (max-width:1023px){.columns.is-variable.is-2-touch{--columnGap:0.5rem}}@media screen and (min-width:1024px){.columns.is-variable.is-2-desktop{--columnGap:0.5rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-2-desktop-only{--columnGap:0.5rem}}@media screen and (min-width:1216px){.columns.is-variable.is-2-widescreen{--columnGap:0.5rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-2-widescreen-only{--columnGap:0.5rem}}@media screen and (min-width:1408px){.columns.is-variable.is-2-fullhd{--columnGap:0.5rem}}.columns.is-variable.is-3{--columnGap:0.75rem}@media screen and (max-width:768px){.columns.is-variable.is-3-mobile{--columnGap:0.75rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-3-tablet{--columnGap:0.75rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-3-tablet-only{--columnGap:0.75rem}}@media screen and (max-width:1023px){.columns.is-variable.is-3-touch{--columnGap:0.75rem}}@media screen and (min-width:1024px){.columns.is-variable.is-3-desktop{--columnGap:0.75rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-3-desktop-only{--columnGap:0.75rem}}@media screen and (min-width:1216px){.columns.is-variable.is-3-widescreen{--columnGap:0.75rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-3-widescreen-only{--columnGap:0.75rem}}@media screen and (min-width:1408px){.columns.is-variable.is-3-fullhd{--columnGap:0.75rem}}.columns.is-variable.is-4{--columnGap:1rem}@media screen and (max-width:768px){.columns.is-variable.is-4-mobile{--columnGap:1rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-4-tablet{--columnGap:1rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-4-tablet-only{--columnGap:1rem}}@media screen and (max-width:1023px){.columns.is-variable.is-4-touch{--columnGap:1rem}}@media screen and (min-width:1024px){.columns.is-variable.is-4-desktop{--columnGap:1rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-4-desktop-only{--columnGap:1rem}}@media screen and (min-width:1216px){.columns.is-variable.is-4-widescreen{--columnGap:1rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-4-widescreen-only{--columnGap:1rem}}@media screen and (min-width:1408px){.columns.is-variable.is-4-fullhd{--columnGap:1rem}}.columns.is-variable.is-5{--columnGap:1.25rem}@media screen and (max-width:768px){.columns.is-variable.is-5-mobile{--columnGap:1.25rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-5-tablet{--columnGap:1.25rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-5-tablet-only{--columnGap:1.25rem}}@media screen and (max-width:1023px){.columns.is-variable.is-5-touch{--columnGap:1.25rem}}@media screen and (min-width:1024px){.columns.is-variable.is-5-desktop{--columnGap:1.25rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-5-desktop-only{--columnGap:1.25rem}}@media screen and (min-width:1216px){.columns.is-variable.is-5-widescreen{--columnGap:1.25rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-5-widescreen-only{--columnGap:1.25rem}}@media screen and (min-width:1408px){.columns.is-variable.is-5-fullhd{--columnGap:1.25rem}}.columns.is-variable.is-6{--columnGap:1.5rem}@media screen and (max-width:768px){.columns.is-variable.is-6-mobile{--columnGap:1.5rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-6-tablet{--columnGap:1.5rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-6-tablet-only{--columnGap:1.5rem}}@media screen and (max-width:1023px){.columns.is-variable.is-6-touch{--columnGap:1.5rem}}@media screen and (min-width:1024px){.columns.is-variable.is-6-desktop{--columnGap:1.5rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-6-desktop-only{--columnGap:1.5rem}}@media screen and (min-width:1216px){.columns.is-variable.is-6-widescreen{--columnGap:1.5rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-6-widescreen-only{--columnGap:1.5rem}}@media screen and (min-width:1408px){.columns.is-variable.is-6-fullhd{--columnGap:1.5rem}}.columns.is-variable.is-7{--columnGap:1.75rem}@media screen and (max-width:768px){.columns.is-variable.is-7-mobile{--columnGap:1.75rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-7-tablet{--columnGap:1.75rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-7-tablet-only{--columnGap:1.75rem}}@media screen and (max-width:1023px){.columns.is-variable.is-7-touch{--columnGap:1.75rem}}@media screen and (min-width:1024px){.columns.is-variable.is-7-desktop{--columnGap:1.75rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-7-desktop-only{--columnGap:1.75rem}}@media screen and (min-width:1216px){.columns.is-variable.is-7-widescreen{--columnGap:1.75rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-7-widescreen-only{--columnGap:1.75rem}}@media screen and (min-width:1408px){.columns.is-variable.is-7-fullhd{--columnGap:1.75rem}}.columns.is-variable.is-8{--columnGap:2rem}@media screen and (max-width:768px){.columns.is-variable.is-8-mobile{--columnGap:2rem}}@media screen and (min-width:769px),print{.columns.is-variable.is-8-tablet{--columnGap:2rem}}@media screen and (min-width:769px) and (max-width:1023px){.columns.is-variable.is-8-tablet-only{--columnGap:2rem}}@media screen and (max-width:1023px){.columns.is-variable.is-8-touch{--columnGap:2rem}}@media screen and (min-width:1024px){.columns.is-variable.is-8-desktop{--columnGap:2rem}}@media screen and (min-width:1024px) and (max-width:1215px){.columns.is-variable.is-8-desktop-only{--columnGap:2rem}}@media screen and (min-width:1216px){.columns.is-variable.is-8-widescreen{--columnGap:2rem}}@media screen and (min-width:1216px) and (max-width:1407px){.columns.is-variable.is-8-widescreen-only{--columnGap:2rem}}@media screen and (min-width:1408px){.columns.is-variable.is-8-fullhd{--columnGap:2rem}}.tile{align-items:stretch;display:block;flex-basis:0;flex-grow:1;flex-shrink:1;min-height:-webkit-min-content;min-height:-moz-min-content;min-height:min-content}.tile.is-ancestor{margin-left:-.75rem;margin-right:-.75rem;margin-top:-.75rem}.tile.is-ancestor:last-child{margin-bottom:-.75rem}.tile.is-ancestor:not(:last-child){margin-bottom:.75rem}.tile.is-child{margin:0!important}.tile.is-parent{padding:.75rem}.tile.is-vertical{flex-direction:column}.tile.is-vertical>.tile.is-child:not(:last-child){margin-bottom:1.5rem!important}@media screen and (min-width:769px),print{.tile:not(.is-child){display:flex}.tile.is-1{flex:none;width:8.33333%}.tile.is-2{flex:none;width:16.66667%}.tile.is-3{flex:none;width:25%}.tile.is-4{flex:none;width:33.33333%}.tile.is-5{flex:none;width:41.66667%}.tile.is-6{flex:none;width:50%}.tile.is-7{flex:none;width:58.33333%}.tile.is-8{flex:none;width:66.66667%}.tile.is-9{flex:none;width:75%}.tile.is-10{flex:none;width:83.33333%}.tile.is-11{flex:none;width:91.66667%}.tile.is-12{flex:none;width:100%}}.has-text-white{color:#fff!important}a.has-text-white:focus,a.has-text-white:hover{color:#e6e6e6!important}.has-background-white{background-color:#fff!important}.has-text-black{color:#0a0a0a!important}a.has-text-black:focus,a.has-text-black:hover{color:#000!important}.has-background-black{background-color:#0a0a0a!important}.has-text-light{color:#f5f5f5!important}a.has-text-light:focus,a.has-text-light:hover{color:#dbdbdb!important}.has-background-light{background-color:#f5f5f5!important}.has-text-dark{color:#363636!important}a.has-text-dark:focus,a.has-text-dark:hover{color:#1c1c1c!important}.has-background-dark{background-color:#363636!important}.has-text-primary{color:#00d1b2!important}a.has-text-primary:focus,a.has-text-primary:hover{color:#009e86!important}.has-background-primary{background-color:#00d1b2!important}.has-text-primary-light{color:#ebfffc!important}a.has-text-primary-light:focus,a.has-text-primary-light:hover{color:#b8fff4!important}.has-background-primary-light{background-color:#ebfffc!important}.has-text-primary-dark{color:#00947e!important}a.has-text-primary-dark:focus,a.has-text-primary-dark:hover{color:#00c7a9!important}.has-background-primary-dark{background-color:#00947e!important}.has-text-link{color:#3273dc!important}a.has-text-link:focus,a.has-text-link:hover{color:#205bbc!important}.has-background-link{background-color:#3273dc!important}.has-text-link-light{color:#eef3fc!important}a.has-text-link-light:focus,a.has-text-link-light:hover{color:#c2d5f5!important}.has-background-link-light{background-color:#eef3fc!important}.has-text-link-dark{color:#2160c4!important}a.has-text-link-dark:focus,a.has-text-link-dark:hover{color:#3b79de!important}.has-background-link-dark{background-color:#2160c4!important}.has-text-info{color:#3298dc!important}a.has-text-info:focus,a.has-text-info:hover{color:#207dbc!important}.has-background-info{background-color:#3298dc!important}.has-text-info-light{color:#eef6fc!important}a.has-text-info-light:focus,a.has-text-info-light:hover{color:#c2e0f5!important}.has-background-info-light{background-color:#eef6fc!important}.has-text-info-dark{color:#1d72aa!important}a.has-text-info-dark:focus,a.has-text-info-dark:hover{color:#248fd6!important}.has-background-info-dark{background-color:#1d72aa!important}.has-text-success{color:#48c774!important}a.has-text-success:focus,a.has-text-success:hover{color:#34a85c!important}.has-background-success{background-color:#48c774!important}.has-text-success-light{color:#effaf3!important}a.has-text-success-light:focus,a.has-text-success-light:hover{color:#c8eed6!important}.has-background-success-light{background-color:#effaf3!important}.has-text-success-dark{color:#257942!important}a.has-text-success-dark:focus,a.has-text-success-dark:hover{color:#31a058!important}.has-background-success-dark{background-color:#257942!important}.has-text-warning{color:#ffdd57!important}a.has-text-warning:focus,a.has-text-warning:hover{color:#ffd324!important}.has-background-warning{background-color:#ffdd57!important}.has-text-warning-light{color:#fffbeb!important}a.has-text-warning-light:focus,a.has-text-warning-light:hover{color:#fff1b8!important}.has-background-warning-light{background-color:#fffbeb!important}.has-text-warning-dark{color:#947600!important}a.has-text-warning-dark:focus,a.has-text-warning-dark:hover{color:#c79f00!important}.has-background-warning-dark{background-color:#947600!important}.has-text-danger{color:#f14668!important}a.has-text-danger:focus,a.has-text-danger:hover{color:#ee1742!important}.has-background-danger{background-color:#f14668!important}.has-text-danger-light{color:#feecf0!important}a.has-text-danger-light:focus,a.has-text-danger-light:hover{color:#fabdc9!important}.has-background-danger-light{background-color:#feecf0!important}.has-text-danger-dark{color:#cc0f35!important}a.has-text-danger-dark:focus,a.has-text-danger-dark:hover{color:#ee2049!important}.has-background-danger-dark{background-color:#cc0f35!important}.has-text-black-bis{color:#121212!important}.has-background-black-bis{background-color:#121212!important}.has-text-black-ter{color:#242424!important}.has-background-black-ter{background-color:#242424!important}.has-text-grey-darker{color:#363636!important}.has-background-grey-darker{background-color:#363636!important}.has-text-grey-dark{color:#4a4a4a!important}.has-background-grey-dark{background-color:#4a4a4a!important}.has-text-grey{color:#7a7a7a!important}.has-background-grey{background-color:#7a7a7a!important}.has-text-grey-light{color:#b5b5b5!important}.has-background-grey-light{background-color:#b5b5b5!important}.has-text-grey-lighter{color:#dbdbdb!important}.has-background-grey-lighter{background-color:#dbdbdb!important}.has-text-white-ter{color:#f5f5f5!important}.has-background-white-ter{background-color:#f5f5f5!important}.has-text-white-bis{color:#fafafa!important}.has-background-white-bis{background-color:#fafafa!important}.is-flex-direction-row{flex-direction:row!important}.is-flex-direction-row-reverse{flex-direction:row-reverse!important}.is-flex-direction-column{flex-direction:column!important}.is-flex-direction-column-reverse{flex-direction:column-reverse!important}.is-flex-wrap-nowrap{flex-wrap:nowrap!important}.is-flex-wrap-wrap{flex-wrap:wrap!important}.is-flex-wrap-wrap-reverse{flex-wrap:wrap-reverse!important}.is-justify-content-flex-start{justify-content:flex-start!important}.is-justify-content-flex-end{justify-content:flex-end!important}.is-justify-content-center{justify-content:center!important}.is-justify-content-space-between{justify-content:space-between!important}.is-justify-content-space-around{justify-content:space-around!important}.is-justify-content-space-evenly{justify-content:space-evenly!important}.is-justify-content-start{justify-content:start!important}.is-justify-content-end{justify-content:end!important}.is-justify-content-left{justify-content:left!important}.is-justify-content-right{justify-content:right!important}.is-align-content-flex-start{align-content:flex-start!important}.is-align-content-flex-end{align-content:flex-end!important}.is-align-content-center{align-content:center!important}.is-align-content-space-between{align-content:space-between!important}.is-align-content-space-around{align-content:space-around!important}.is-align-content-space-evenly{align-content:space-evenly!important}.is-align-content-stretch{align-content:stretch!important}.is-align-content-start{align-content:start!important}.is-align-content-end{align-content:end!important}.is-align-content-baseline{align-content:baseline!important}.is-align-items-stretch{align-items:stretch!important}.is-align-items-flex-start{align-items:flex-start!important}.is-align-items-flex-end{align-items:flex-end!important}.is-align-items-center{align-items:center!important}.is-align-items-baseline{align-items:baseline!important}.is-align-items-start{align-items:start!important}.is-align-items-end{align-items:end!important}.is-align-items-self-start{align-items:self-start!important}.is-align-items-self-end{align-items:self-end!important}.is-align-self-auto{align-self:auto!important}.is-align-self-flex-start{align-self:flex-start!important}.is-align-self-flex-end{align-self:flex-end!important}.is-align-self-center{align-self:center!important}.is-align-self-baseline{align-self:baseline!important}.is-align-self-stretch{align-self:stretch!important}.is-flex-grow-0{flex-grow:0!important}.is-flex-grow-1{flex-grow:1!important}.is-flex-grow-2{flex-grow:2!important}.is-flex-grow-3{flex-grow:3!important}.is-flex-grow-4{flex-grow:4!important}.is-flex-grow-5{flex-grow:5!important}.is-flex-shrink-0{flex-shrink:0!important}.is-flex-shrink-1{flex-shrink:1!important}.is-flex-shrink-2{flex-shrink:2!important}.is-flex-shrink-3{flex-shrink:3!important}.is-flex-shrink-4{flex-shrink:4!important}.is-flex-shrink-5{flex-shrink:5!important}.is-clearfix::after{clear:both;content:" ";display:table}.is-pulled-left{float:left!important}.is-pulled-right{float:right!important}.is-radiusless{border-radius:0!important}.is-shadowless{box-shadow:none!important}.is-clickable{cursor:pointer!important;pointer-events:all!important}.is-clipped{overflow:hidden!important}.is-relative{position:relative!important}.is-marginless{margin:0!important}.is-paddingless{padding:0!important}.m-0{margin:0!important}.mt-0{margin-top:0!important}.mr-0{margin-right:0!important}.mb-0{margin-bottom:0!important}.ml-0{margin-left:0!important}.mx-0{margin-left:0!important;margin-right:0!important}.my-0{margin-top:0!important;margin-bottom:0!important}.m-1{margin:.25rem!important}.mt-1{margin-top:.25rem!important}.mr-1{margin-right:.25rem!important}.mb-1{margin-bottom:.25rem!important}.ml-1{margin-left:.25rem!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.m-2{margin:.5rem!important}.mt-2{margin-top:.5rem!important}.mr-2{margin-right:.5rem!important}.mb-2{margin-bottom:.5rem!important}.ml-2{margin-left:.5rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.m-3{margin:.75rem!important}.mt-3{margin-top:.75rem!important}.mr-3{margin-right:.75rem!important}.mb-3{margin-bottom:.75rem!important}.ml-3{margin-left:.75rem!important}.mx-3{margin-left:.75rem!important;margin-right:.75rem!important}.my-3{margin-top:.75rem!important;margin-bottom:.75rem!important}.m-4{margin:1rem!important}.mt-4{margin-top:1rem!important}.mr-4{margin-right:1rem!important}.mb-4{margin-bottom:1rem!important}.ml-4{margin-left:1rem!important}.mx-4{margin-left:1rem!important;margin-right:1rem!important}.my-4{margin-top:1rem!important;margin-bottom:1rem!important}.m-5{margin:1.5rem!important}.mt-5{margin-top:1.5rem!important}.mr-5{margin-right:1.5rem!important}.mb-5{margin-bottom:1.5rem!important}.ml-5{margin-left:1.5rem!important}.mx-5{margin-left:1.5rem!important;margin-right:1.5rem!important}.my-5{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.m-6{margin:3rem!important}.mt-6{margin-top:3rem!important}.mr-6{margin-right:3rem!important}.mb-6{margin-bottom:3rem!important}.ml-6{margin-left:3rem!important}.mx-6{margin-left:3rem!important;margin-right:3rem!important}.my-6{margin-top:3rem!important;margin-bottom:3rem!important}.p-0{padding:0!important}.pt-0{padding-top:0!important}.pr-0{padding-right:0!important}.pb-0{padding-bottom:0!important}.pl-0{padding-left:0!important}.px-0{padding-left:0!important;padding-right:0!important}.py-0{padding-top:0!important;padding-bottom:0!important}.p-1{padding:.25rem!important}.pt-1{padding-top:.25rem!important}.pr-1{padding-right:.25rem!important}.pb-1{padding-bottom:.25rem!important}.pl-1{padding-left:.25rem!important}.px-1{padding-left:.25rem!important;padding-right:.25rem!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.p-2{padding:.5rem!important}.pt-2{padding-top:.5rem!important}.pr-2{padding-right:.5rem!important}.pb-2{padding-bottom:.5rem!important}.pl-2{padding-left:.5rem!important}.px-2{padding-left:.5rem!important;padding-right:.5rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.p-3{padding:.75rem!important}.pt-3{padding-top:.75rem!important}.pr-3{padding-right:.75rem!important}.pb-3{padding-bottom:.75rem!important}.pl-3{padding-left:.75rem!important}.px-3{padding-left:.75rem!important;padding-right:.75rem!important}.py-3{padding-top:.75rem!important;padding-bottom:.75rem!important}.p-4{padding:1rem!important}.pt-4{padding-top:1rem!important}.pr-4{padding-right:1rem!important}.pb-4{padding-bottom:1rem!important}.pl-4{padding-left:1rem!important}.px-4{padding-left:1rem!important;padding-right:1rem!important}.py-4{padding-top:1rem!important;padding-bottom:1rem!important}.p-5{padding:1.5rem!important}.pt-5{padding-top:1.5rem!important}.pr-5{padding-right:1.5rem!important}.pb-5{padding-bottom:1.5rem!important}.pl-5{padding-left:1.5rem!important}.px-5{padding-left:1.5rem!important;padding-right:1.5rem!important}.py-5{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.p-6{padding:3rem!important}.pt-6{padding-top:3rem!important}.pr-6{padding-right:3rem!important}.pb-6{padding-bottom:3rem!important}.pl-6{padding-left:3rem!important}.px-6{padding-left:3rem!important;padding-right:3rem!important}.py-6{padding-top:3rem!important;padding-bottom:3rem!important}.is-size-1{font-size:3rem!important}.is-size-2{font-size:2.5rem!important}.is-size-3{font-size:2rem!important}.is-size-4{font-size:1.5rem!important}.is-size-5{font-size:1.25rem!important}.is-size-6{font-size:1rem!important}.is-size-7{font-size:.75rem!important}@media screen and (max-width:768px){.is-size-1-mobile{font-size:3rem!important}.is-size-2-mobile{font-size:2.5rem!important}.is-size-3-mobile{font-size:2rem!important}.is-size-4-mobile{font-size:1.5rem!important}.is-size-5-mobile{font-size:1.25rem!important}.is-size-6-mobile{font-size:1rem!important}.is-size-7-mobile{font-size:.75rem!important}}@media screen and (min-width:769px),print{.is-size-1-tablet{font-size:3rem!important}.is-size-2-tablet{font-size:2.5rem!important}.is-size-3-tablet{font-size:2rem!important}.is-size-4-tablet{font-size:1.5rem!important}.is-size-5-tablet{font-size:1.25rem!important}.is-size-6-tablet{font-size:1rem!important}.is-size-7-tablet{font-size:.75rem!important}}@media screen and (max-width:1023px){.is-size-1-touch{font-size:3rem!important}.is-size-2-touch{font-size:2.5rem!important}.is-size-3-touch{font-size:2rem!important}.is-size-4-touch{font-size:1.5rem!important}.is-size-5-touch{font-size:1.25rem!important}.is-size-6-touch{font-size:1rem!important}.is-size-7-touch{font-size:.75rem!important}}@media screen and (min-width:1024px){.is-size-1-desktop{font-size:3rem!important}.is-size-2-desktop{font-size:2.5rem!important}.is-size-3-desktop{font-size:2rem!important}.is-size-4-desktop{font-size:1.5rem!important}.is-size-5-desktop{font-size:1.25rem!important}.is-size-6-desktop{font-size:1rem!important}.is-size-7-desktop{font-size:.75rem!important}}@media screen and (min-width:1216px){.is-size-1-widescreen{font-size:3rem!important}.is-size-2-widescreen{font-size:2.5rem!important}.is-size-3-widescreen{font-size:2rem!important}.is-size-4-widescreen{font-size:1.5rem!important}.is-size-5-widescreen{font-size:1.25rem!important}.is-size-6-widescreen{font-size:1rem!important}.is-size-7-widescreen{font-size:.75rem!important}}@media screen and (min-width:1408px){.is-size-1-fullhd{font-size:3rem!important}.is-size-2-fullhd{font-size:2.5rem!important}.is-size-3-fullhd{font-size:2rem!important}.is-size-4-fullhd{font-size:1.5rem!important}.is-size-5-fullhd{font-size:1.25rem!important}.is-size-6-fullhd{font-size:1rem!important}.is-size-7-fullhd{font-size:.75rem!important}}.has-text-centered{text-align:center!important}.has-text-justified{text-align:justify!important}.has-text-left{text-align:left!important}.has-text-right{text-align:right!important}@media screen and (max-width:768px){.has-text-centered-mobile{text-align:center!important}}@media screen and (min-width:769px),print{.has-text-centered-tablet{text-align:center!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-centered-tablet-only{text-align:center!important}}@media screen and (max-width:1023px){.has-text-centered-touch{text-align:center!important}}@media screen and (min-width:1024px){.has-text-centered-desktop{text-align:center!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-centered-desktop-only{text-align:center!important}}@media screen and (min-width:1216px){.has-text-centered-widescreen{text-align:center!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-centered-widescreen-only{text-align:center!important}}@media screen and (min-width:1408px){.has-text-centered-fullhd{text-align:center!important}}@media screen and (max-width:768px){.has-text-justified-mobile{text-align:justify!important}}@media screen and (min-width:769px),print{.has-text-justified-tablet{text-align:justify!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-justified-tablet-only{text-align:justify!important}}@media screen and (max-width:1023px){.has-text-justified-touch{text-align:justify!important}}@media screen and (min-width:1024px){.has-text-justified-desktop{text-align:justify!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-justified-desktop-only{text-align:justify!important}}@media screen and (min-width:1216px){.has-text-justified-widescreen{text-align:justify!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-justified-widescreen-only{text-align:justify!important}}@media screen and (min-width:1408px){.has-text-justified-fullhd{text-align:justify!important}}@media screen and (max-width:768px){.has-text-left-mobile{text-align:left!important}}@media screen and (min-width:769px),print{.has-text-left-tablet{text-align:left!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-left-tablet-only{text-align:left!important}}@media screen and (max-width:1023px){.has-text-left-touch{text-align:left!important}}@media screen and (min-width:1024px){.has-text-left-desktop{text-align:left!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-left-desktop-only{text-align:left!important}}@media screen and (min-width:1216px){.has-text-left-widescreen{text-align:left!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-left-widescreen-only{text-align:left!important}}@media screen and (min-width:1408px){.has-text-left-fullhd{text-align:left!important}}@media screen and (max-width:768px){.has-text-right-mobile{text-align:right!important}}@media screen and (min-width:769px),print{.has-text-right-tablet{text-align:right!important}}@media screen and (min-width:769px) and (max-width:1023px){.has-text-right-tablet-only{text-align:right!important}}@media screen and (max-width:1023px){.has-text-right-touch{text-align:right!important}}@media screen and (min-width:1024px){.has-text-right-desktop{text-align:right!important}}@media screen and (min-width:1024px) and (max-width:1215px){.has-text-right-desktop-only{text-align:right!important}}@media screen and (min-width:1216px){.has-text-right-widescreen{text-align:right!important}}@media screen and (min-width:1216px) and (max-width:1407px){.has-text-right-widescreen-only{text-align:right!important}}@media screen and (min-width:1408px){.has-text-right-fullhd{text-align:right!important}}.is-capitalized{text-transform:capitalize!important}.is-lowercase{text-transform:lowercase!important}.is-uppercase{text-transform:uppercase!important}.is-italic{font-style:italic!important}.has-text-weight-light{font-weight:300!important}.has-text-weight-normal{font-weight:400!important}.has-text-weight-medium{font-weight:500!important}.has-text-weight-semibold{font-weight:600!important}.has-text-weight-bold{font-weight:700!important}.is-family-primary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-secondary{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-sans-serif{font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif!important}.is-family-monospace{font-family:monospace!important}.is-family-code{font-family:monospace!important}.is-block{display:block!important}@media screen and (max-width:768px){.is-block-mobile{display:block!important}}@media screen and (min-width:769px),print{.is-block-tablet{display:block!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-block-tablet-only{display:block!important}}@media screen and (max-width:1023px){.is-block-touch{display:block!important}}@media screen and (min-width:1024px){.is-block-desktop{display:block!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-block-desktop-only{display:block!important}}@media screen and (min-width:1216px){.is-block-widescreen{display:block!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-block-widescreen-only{display:block!important}}@media screen and (min-width:1408px){.is-block-fullhd{display:block!important}}.is-flex{display:flex!important}@media screen and (max-width:768px){.is-flex-mobile{display:flex!important}}@media screen and (min-width:769px),print{.is-flex-tablet{display:flex!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-flex-tablet-only{display:flex!important}}@media screen and (max-width:1023px){.is-flex-touch{display:flex!important}}@media screen and (min-width:1024px){.is-flex-desktop{display:flex!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-flex-desktop-only{display:flex!important}}@media screen and (min-width:1216px){.is-flex-widescreen{display:flex!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-flex-widescreen-only{display:flex!important}}@media screen and (min-width:1408px){.is-flex-fullhd{display:flex!important}}.is-inline{display:inline!important}@media screen and (max-width:768px){.is-inline-mobile{display:inline!important}}@media screen and (min-width:769px),print{.is-inline-tablet{display:inline!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-tablet-only{display:inline!important}}@media screen and (max-width:1023px){.is-inline-touch{display:inline!important}}@media screen and (min-width:1024px){.is-inline-desktop{display:inline!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-desktop-only{display:inline!important}}@media screen and (min-width:1216px){.is-inline-widescreen{display:inline!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-widescreen-only{display:inline!important}}@media screen and (min-width:1408px){.is-inline-fullhd{display:inline!important}}.is-inline-block{display:inline-block!important}@media screen and (max-width:768px){.is-inline-block-mobile{display:inline-block!important}}@media screen and (min-width:769px),print{.is-inline-block-tablet{display:inline-block!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-block-tablet-only{display:inline-block!important}}@media screen and (max-width:1023px){.is-inline-block-touch{display:inline-block!important}}@media screen and (min-width:1024px){.is-inline-block-desktop{display:inline-block!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-block-desktop-only{display:inline-block!important}}@media screen and (min-width:1216px){.is-inline-block-widescreen{display:inline-block!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-block-widescreen-only{display:inline-block!important}}@media screen and (min-width:1408px){.is-inline-block-fullhd{display:inline-block!important}}.is-inline-flex{display:inline-flex!important}@media screen and (max-width:768px){.is-inline-flex-mobile{display:inline-flex!important}}@media screen and (min-width:769px),print{.is-inline-flex-tablet{display:inline-flex!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-inline-flex-tablet-only{display:inline-flex!important}}@media screen and (max-width:1023px){.is-inline-flex-touch{display:inline-flex!important}}@media screen and (min-width:1024px){.is-inline-flex-desktop{display:inline-flex!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-inline-flex-desktop-only{display:inline-flex!important}}@media screen and (min-width:1216px){.is-inline-flex-widescreen{display:inline-flex!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-inline-flex-widescreen-only{display:inline-flex!important}}@media screen and (min-width:1408px){.is-inline-flex-fullhd{display:inline-flex!important}}.is-hidden{display:none!important}.is-sr-only{border:none!important;clip:rect(0,0,0,0)!important;height:.01em!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:.01em!important}@media screen and (max-width:768px){.is-hidden-mobile{display:none!important}}@media screen and (min-width:769px),print{.is-hidden-tablet{display:none!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-hidden-tablet-only{display:none!important}}@media screen and (max-width:1023px){.is-hidden-touch{display:none!important}}@media screen and (min-width:1024px){.is-hidden-desktop{display:none!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-hidden-desktop-only{display:none!important}}@media screen and (min-width:1216px){.is-hidden-widescreen{display:none!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-hidden-widescreen-only{display:none!important}}@media screen and (min-width:1408px){.is-hidden-fullhd{display:none!important}}.is-invisible{visibility:hidden!important}@media screen and (max-width:768px){.is-invisible-mobile{visibility:hidden!important}}@media screen and (min-width:769px),print{.is-invisible-tablet{visibility:hidden!important}}@media screen and (min-width:769px) and (max-width:1023px){.is-invisible-tablet-only{visibility:hidden!important}}@media screen and (max-width:1023px){.is-invisible-touch{visibility:hidden!important}}@media screen and (min-width:1024px){.is-invisible-desktop{visibility:hidden!important}}@media screen and (min-width:1024px) and (max-width:1215px){.is-invisible-desktop-only{visibility:hidden!important}}@media screen and (min-width:1216px){.is-invisible-widescreen{visibility:hidden!important}}@media screen and (min-width:1216px) and (max-width:1407px){.is-invisible-widescreen-only{visibility:hidden!important}}@media screen and (min-width:1408px){.is-invisible-fullhd{visibility:hidden!important}}.hero{align-items:stretch;display:flex;flex-direction:column;justify-content:space-between}.hero .navbar{background:0 0}.hero .tabs ul{border-bottom:none}.hero.is-white{background-color:#fff;color:#0a0a0a}.hero.is-white a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-white strong{color:inherit}.hero.is-white .title{color:#0a0a0a}.hero.is-white .subtitle{color:rgba(10,10,10,.9)}.hero.is-white .subtitle a:not(.button),.hero.is-white .subtitle strong{color:#0a0a0a}@media screen and (max-width:1023px){.hero.is-white .navbar-menu{background-color:#fff}}.hero.is-white .navbar-item,.hero.is-white .navbar-link{color:rgba(10,10,10,.7)}.hero.is-white .navbar-link.is-active,.hero.is-white .navbar-link:hover,.hero.is-white a.navbar-item.is-active,.hero.is-white a.navbar-item:hover{background-color:#f2f2f2;color:#0a0a0a}.hero.is-white .tabs a{color:#0a0a0a;opacity:.9}.hero.is-white .tabs a:hover{opacity:1}.hero.is-white .tabs li.is-active a{opacity:1}.hero.is-white .tabs.is-boxed a,.hero.is-white .tabs.is-toggle a{color:#0a0a0a}.hero.is-white .tabs.is-boxed a:hover,.hero.is-white .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-white .tabs.is-boxed li.is-active a,.hero.is-white .tabs.is-boxed li.is-active a:hover,.hero.is-white .tabs.is-toggle li.is-active a,.hero.is-white .tabs.is-toggle li.is-active a:hover{background-color:#0a0a0a;border-color:#0a0a0a;color:#fff}.hero.is-white.is-bold{background-image:linear-gradient(141deg,#e6e6e6 0,#fff 71%,#fff 100%)}@media screen and (max-width:768px){.hero.is-white.is-bold .navbar-menu{background-image:linear-gradient(141deg,#e6e6e6 0,#fff 71%,#fff 100%)}}.hero.is-black{background-color:#0a0a0a;color:#fff}.hero.is-black a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-black strong{color:inherit}.hero.is-black .title{color:#fff}.hero.is-black .subtitle{color:rgba(255,255,255,.9)}.hero.is-black .subtitle a:not(.button),.hero.is-black .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-black .navbar-menu{background-color:#0a0a0a}}.hero.is-black .navbar-item,.hero.is-black .navbar-link{color:rgba(255,255,255,.7)}.hero.is-black .navbar-link.is-active,.hero.is-black .navbar-link:hover,.hero.is-black a.navbar-item.is-active,.hero.is-black a.navbar-item:hover{background-color:#000;color:#fff}.hero.is-black .tabs a{color:#fff;opacity:.9}.hero.is-black .tabs a:hover{opacity:1}.hero.is-black .tabs li.is-active a{opacity:1}.hero.is-black .tabs.is-boxed a,.hero.is-black .tabs.is-toggle a{color:#fff}.hero.is-black .tabs.is-boxed a:hover,.hero.is-black .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-black .tabs.is-boxed li.is-active a,.hero.is-black .tabs.is-boxed li.is-active a:hover,.hero.is-black .tabs.is-toggle li.is-active a,.hero.is-black .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#0a0a0a}.hero.is-black.is-bold{background-image:linear-gradient(141deg,#000 0,#0a0a0a 71%,#181616 100%)}@media screen and (max-width:768px){.hero.is-black.is-bold .navbar-menu{background-image:linear-gradient(141deg,#000 0,#0a0a0a 71%,#181616 100%)}}.hero.is-light{background-color:#f5f5f5;color:rgba(0,0,0,.7)}.hero.is-light a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-light strong{color:inherit}.hero.is-light .title{color:rgba(0,0,0,.7)}.hero.is-light .subtitle{color:rgba(0,0,0,.9)}.hero.is-light .subtitle a:not(.button),.hero.is-light .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width:1023px){.hero.is-light .navbar-menu{background-color:#f5f5f5}}.hero.is-light .navbar-item,.hero.is-light .navbar-link{color:rgba(0,0,0,.7)}.hero.is-light .navbar-link.is-active,.hero.is-light .navbar-link:hover,.hero.is-light a.navbar-item.is-active,.hero.is-light a.navbar-item:hover{background-color:#e8e8e8;color:rgba(0,0,0,.7)}.hero.is-light .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-light .tabs a:hover{opacity:1}.hero.is-light .tabs li.is-active a{opacity:1}.hero.is-light .tabs.is-boxed a,.hero.is-light .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-light .tabs.is-boxed a:hover,.hero.is-light .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-light .tabs.is-boxed li.is-active a,.hero.is-light .tabs.is-boxed li.is-active a:hover,.hero.is-light .tabs.is-toggle li.is-active a,.hero.is-light .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:#f5f5f5}.hero.is-light.is-bold{background-image:linear-gradient(141deg,#dfd8d9 0,#f5f5f5 71%,#fff 100%)}@media screen and (max-width:768px){.hero.is-light.is-bold .navbar-menu{background-image:linear-gradient(141deg,#dfd8d9 0,#f5f5f5 71%,#fff 100%)}}.hero.is-dark{background-color:#363636;color:#fff}.hero.is-dark a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-dark strong{color:inherit}.hero.is-dark .title{color:#fff}.hero.is-dark .subtitle{color:rgba(255,255,255,.9)}.hero.is-dark .subtitle a:not(.button),.hero.is-dark .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-dark .navbar-menu{background-color:#363636}}.hero.is-dark .navbar-item,.hero.is-dark .navbar-link{color:rgba(255,255,255,.7)}.hero.is-dark .navbar-link.is-active,.hero.is-dark .navbar-link:hover,.hero.is-dark a.navbar-item.is-active,.hero.is-dark a.navbar-item:hover{background-color:#292929;color:#fff}.hero.is-dark .tabs a{color:#fff;opacity:.9}.hero.is-dark .tabs a:hover{opacity:1}.hero.is-dark .tabs li.is-active a{opacity:1}.hero.is-dark .tabs.is-boxed a,.hero.is-dark .tabs.is-toggle a{color:#fff}.hero.is-dark .tabs.is-boxed a:hover,.hero.is-dark .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-dark .tabs.is-boxed li.is-active a,.hero.is-dark .tabs.is-boxed li.is-active a:hover,.hero.is-dark .tabs.is-toggle li.is-active a,.hero.is-dark .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#363636}.hero.is-dark.is-bold{background-image:linear-gradient(141deg,#1f191a 0,#363636 71%,#46403f 100%)}@media screen and (max-width:768px){.hero.is-dark.is-bold .navbar-menu{background-image:linear-gradient(141deg,#1f191a 0,#363636 71%,#46403f 100%)}}.hero.is-primary{background-color:#00d1b2;color:#fff}.hero.is-primary a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-primary strong{color:inherit}.hero.is-primary .title{color:#fff}.hero.is-primary .subtitle{color:rgba(255,255,255,.9)}.hero.is-primary .subtitle a:not(.button),.hero.is-primary .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-primary .navbar-menu{background-color:#00d1b2}}.hero.is-primary .navbar-item,.hero.is-primary .navbar-link{color:rgba(255,255,255,.7)}.hero.is-primary .navbar-link.is-active,.hero.is-primary .navbar-link:hover,.hero.is-primary a.navbar-item.is-active,.hero.is-primary a.navbar-item:hover{background-color:#00b89c;color:#fff}.hero.is-primary .tabs a{color:#fff;opacity:.9}.hero.is-primary .tabs a:hover{opacity:1}.hero.is-primary .tabs li.is-active a{opacity:1}.hero.is-primary .tabs.is-boxed a,.hero.is-primary .tabs.is-toggle a{color:#fff}.hero.is-primary .tabs.is-boxed a:hover,.hero.is-primary .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-primary .tabs.is-boxed li.is-active a,.hero.is-primary .tabs.is-boxed li.is-active a:hover,.hero.is-primary .tabs.is-toggle li.is-active a,.hero.is-primary .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#00d1b2}.hero.is-primary.is-bold{background-image:linear-gradient(141deg,#009e6c 0,#00d1b2 71%,#00e7eb 100%)}@media screen and (max-width:768px){.hero.is-primary.is-bold .navbar-menu{background-image:linear-gradient(141deg,#009e6c 0,#00d1b2 71%,#00e7eb 100%)}}.hero.is-link{background-color:#3273dc;color:#fff}.hero.is-link a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-link strong{color:inherit}.hero.is-link .title{color:#fff}.hero.is-link .subtitle{color:rgba(255,255,255,.9)}.hero.is-link .subtitle a:not(.button),.hero.is-link .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-link .navbar-menu{background-color:#3273dc}}.hero.is-link .navbar-item,.hero.is-link .navbar-link{color:rgba(255,255,255,.7)}.hero.is-link .navbar-link.is-active,.hero.is-link .navbar-link:hover,.hero.is-link a.navbar-item.is-active,.hero.is-link a.navbar-item:hover{background-color:#2366d1;color:#fff}.hero.is-link .tabs a{color:#fff;opacity:.9}.hero.is-link .tabs a:hover{opacity:1}.hero.is-link .tabs li.is-active a{opacity:1}.hero.is-link .tabs.is-boxed a,.hero.is-link .tabs.is-toggle a{color:#fff}.hero.is-link .tabs.is-boxed a:hover,.hero.is-link .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-link .tabs.is-boxed li.is-active a,.hero.is-link .tabs.is-boxed li.is-active a:hover,.hero.is-link .tabs.is-toggle li.is-active a,.hero.is-link .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#3273dc}.hero.is-link.is-bold{background-image:linear-gradient(141deg,#1577c6 0,#3273dc 71%,#4366e5 100%)}@media screen and (max-width:768px){.hero.is-link.is-bold .navbar-menu{background-image:linear-gradient(141deg,#1577c6 0,#3273dc 71%,#4366e5 100%)}}.hero.is-info{background-color:#3298dc;color:#fff}.hero.is-info a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-info strong{color:inherit}.hero.is-info .title{color:#fff}.hero.is-info .subtitle{color:rgba(255,255,255,.9)}.hero.is-info .subtitle a:not(.button),.hero.is-info .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-info .navbar-menu{background-color:#3298dc}}.hero.is-info .navbar-item,.hero.is-info .navbar-link{color:rgba(255,255,255,.7)}.hero.is-info .navbar-link.is-active,.hero.is-info .navbar-link:hover,.hero.is-info a.navbar-item.is-active,.hero.is-info a.navbar-item:hover{background-color:#238cd1;color:#fff}.hero.is-info .tabs a{color:#fff;opacity:.9}.hero.is-info .tabs a:hover{opacity:1}.hero.is-info .tabs li.is-active a{opacity:1}.hero.is-info .tabs.is-boxed a,.hero.is-info .tabs.is-toggle a{color:#fff}.hero.is-info .tabs.is-boxed a:hover,.hero.is-info .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-info .tabs.is-boxed li.is-active a,.hero.is-info .tabs.is-boxed li.is-active a:hover,.hero.is-info .tabs.is-toggle li.is-active a,.hero.is-info .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#3298dc}.hero.is-info.is-bold{background-image:linear-gradient(141deg,#159dc6 0,#3298dc 71%,#4389e5 100%)}@media screen and (max-width:768px){.hero.is-info.is-bold .navbar-menu{background-image:linear-gradient(141deg,#159dc6 0,#3298dc 71%,#4389e5 100%)}}.hero.is-success{background-color:#48c774;color:#fff}.hero.is-success a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-success strong{color:inherit}.hero.is-success .title{color:#fff}.hero.is-success .subtitle{color:rgba(255,255,255,.9)}.hero.is-success .subtitle a:not(.button),.hero.is-success .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-success .navbar-menu{background-color:#48c774}}.hero.is-success .navbar-item,.hero.is-success .navbar-link{color:rgba(255,255,255,.7)}.hero.is-success .navbar-link.is-active,.hero.is-success .navbar-link:hover,.hero.is-success a.navbar-item.is-active,.hero.is-success a.navbar-item:hover{background-color:#3abb67;color:#fff}.hero.is-success .tabs a{color:#fff;opacity:.9}.hero.is-success .tabs a:hover{opacity:1}.hero.is-success .tabs li.is-active a{opacity:1}.hero.is-success .tabs.is-boxed a,.hero.is-success .tabs.is-toggle a{color:#fff}.hero.is-success .tabs.is-boxed a:hover,.hero.is-success .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-success .tabs.is-boxed li.is-active a,.hero.is-success .tabs.is-boxed li.is-active a:hover,.hero.is-success .tabs.is-toggle li.is-active a,.hero.is-success .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#48c774}.hero.is-success.is-bold{background-image:linear-gradient(141deg,#29b342 0,#48c774 71%,#56d296 100%)}@media screen and (max-width:768px){.hero.is-success.is-bold .navbar-menu{background-image:linear-gradient(141deg,#29b342 0,#48c774 71%,#56d296 100%)}}.hero.is-warning{background-color:#ffdd57;color:rgba(0,0,0,.7)}.hero.is-warning a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-warning strong{color:inherit}.hero.is-warning .title{color:rgba(0,0,0,.7)}.hero.is-warning .subtitle{color:rgba(0,0,0,.9)}.hero.is-warning .subtitle a:not(.button),.hero.is-warning .subtitle strong{color:rgba(0,0,0,.7)}@media screen and (max-width:1023px){.hero.is-warning .navbar-menu{background-color:#ffdd57}}.hero.is-warning .navbar-item,.hero.is-warning .navbar-link{color:rgba(0,0,0,.7)}.hero.is-warning .navbar-link.is-active,.hero.is-warning .navbar-link:hover,.hero.is-warning a.navbar-item.is-active,.hero.is-warning a.navbar-item:hover{background-color:#ffd83d;color:rgba(0,0,0,.7)}.hero.is-warning .tabs a{color:rgba(0,0,0,.7);opacity:.9}.hero.is-warning .tabs a:hover{opacity:1}.hero.is-warning .tabs li.is-active a{opacity:1}.hero.is-warning .tabs.is-boxed a,.hero.is-warning .tabs.is-toggle a{color:rgba(0,0,0,.7)}.hero.is-warning .tabs.is-boxed a:hover,.hero.is-warning .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-warning .tabs.is-boxed li.is-active a,.hero.is-warning .tabs.is-boxed li.is-active a:hover,.hero.is-warning .tabs.is-toggle li.is-active a,.hero.is-warning .tabs.is-toggle li.is-active a:hover{background-color:rgba(0,0,0,.7);border-color:rgba(0,0,0,.7);color:#ffdd57}.hero.is-warning.is-bold{background-image:linear-gradient(141deg,#ffaf24 0,#ffdd57 71%,#fffa70 100%)}@media screen and (max-width:768px){.hero.is-warning.is-bold .navbar-menu{background-image:linear-gradient(141deg,#ffaf24 0,#ffdd57 71%,#fffa70 100%)}}.hero.is-danger{background-color:#f14668;color:#fff}.hero.is-danger a:not(.button):not(.dropdown-item):not(.tag):not(.pagination-link.is-current),.hero.is-danger strong{color:inherit}.hero.is-danger .title{color:#fff}.hero.is-danger .subtitle{color:rgba(255,255,255,.9)}.hero.is-danger .subtitle a:not(.button),.hero.is-danger .subtitle strong{color:#fff}@media screen and (max-width:1023px){.hero.is-danger .navbar-menu{background-color:#f14668}}.hero.is-danger .navbar-item,.hero.is-danger .navbar-link{color:rgba(255,255,255,.7)}.hero.is-danger .navbar-link.is-active,.hero.is-danger .navbar-link:hover,.hero.is-danger a.navbar-item.is-active,.hero.is-danger a.navbar-item:hover{background-color:#ef2e55;color:#fff}.hero.is-danger .tabs a{color:#fff;opacity:.9}.hero.is-danger .tabs a:hover{opacity:1}.hero.is-danger .tabs li.is-active a{opacity:1}.hero.is-danger .tabs.is-boxed a,.hero.is-danger .tabs.is-toggle a{color:#fff}.hero.is-danger .tabs.is-boxed a:hover,.hero.is-danger .tabs.is-toggle a:hover{background-color:rgba(10,10,10,.1)}.hero.is-danger .tabs.is-boxed li.is-active a,.hero.is-danger .tabs.is-boxed li.is-active a:hover,.hero.is-danger .tabs.is-toggle li.is-active a,.hero.is-danger .tabs.is-toggle li.is-active a:hover{background-color:#fff;border-color:#fff;color:#f14668}.hero.is-danger.is-bold{background-image:linear-gradient(141deg,#fa0a62 0,#f14668 71%,#f7595f 100%)}@media screen and (max-width:768px){.hero.is-danger.is-bold .navbar-menu{background-image:linear-gradient(141deg,#fa0a62 0,#f14668 71%,#f7595f 100%)}}.hero.is-small .hero-body{padding:1.5rem}@media screen and (min-width:769px),print{.hero.is-medium .hero-body{padding:9rem 1.5rem}}@media screen and (min-width:769px),print{.hero.is-large .hero-body{padding:18rem 1.5rem}}.hero.is-fullheight .hero-body,.hero.is-fullheight-with-navbar .hero-body,.hero.is-halfheight .hero-body{align-items:center;display:flex}.hero.is-fullheight .hero-body>.container,.hero.is-fullheight-with-navbar .hero-body>.container,.hero.is-halfheight .hero-body>.container{flex-grow:1;flex-shrink:1}.hero.is-halfheight{min-height:50vh}.hero.is-fullheight{min-height:100vh}.hero-video{overflow:hidden}.hero-video video{left:50%;min-height:100%;min-width:100%;position:absolute;top:50%;transform:translate3d(-50%,-50%,0)}.hero-video.is-transparent{opacity:.3}@media screen and (max-width:768px){.hero-video{display:none}}.hero-buttons{margin-top:1.5rem}@media screen and (max-width:768px){.hero-buttons .button{display:flex}.hero-buttons .button:not(:last-child){margin-bottom:.75rem}}@media screen and (min-width:769px),print{.hero-buttons{display:flex;justify-content:center}.hero-buttons .button:not(:last-child){margin-right:1.5rem}}.hero-foot,.hero-head{flex-grow:0;flex-shrink:0}.hero-body{flex-grow:1;flex-shrink:0;padding:3rem 1.5rem}.section{padding:3rem 1.5rem}@media screen and (min-width:1024px){.section.is-medium{padding:9rem 1.5rem}.section.is-large{padding:18rem 1.5rem}}.footer{background-color:#fafafa;padding:3rem 1.5rem 6rem}
diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css
new file mode 100644
index 00000000..c78af145
--- /dev/null
+++ b/bookwyrm/static/css/vendor/icons.css
@@ -0,0 +1,156 @@
+
+/** @todo Replace icons with SVG symbols.
+ @see https://www.youtube.com/watch?v=9xXBYcWgCHA */
+@font-face {
+ font-family: 'icomoon';
+ src: url('../fonts/icomoon.eot?n5x55');
+ src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
+ url('../fonts/icomoon.ttf?n5x55') format('truetype'),
+ url('../fonts/icomoon.woff?n5x55') format('woff'),
+ url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
+ font-weight: normal;
+ font-style: normal;
+ font-display: block;
+}
+
+[class^="icon-"], [class*=" icon-"] {
+ /* use !important to prevent issues with browser extensions that change fonts */
+ font-family: 'icomoon' !important;
+ speak: never;
+ font-style: normal;
+ font-weight: normal;
+ font-variant: normal;
+ text-transform: none;
+ line-height: 1;
+
+ /* Better Font Rendering =========== */
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-graphic-heart:before {
+ content: "\e91e";
+}
+.icon-graphic-paperplane:before {
+ content: "\e91f";
+}
+.icon-graphic-banknote:before {
+ content: "\e920";
+}
+.icon-stars:before {
+ content: "\e91a";
+}
+.icon-warning:before {
+ content: "\e91b";
+}
+.icon-book:before {
+ content: "\e900";
+}
+.icon-bookmark:before {
+ content: "\e91c";
+}
+.icon-rss:before {
+ content: "\e91d";
+}
+.icon-envelope:before {
+ content: "\e901";
+}
+.icon-arrow-right:before {
+ content: "\e902";
+}
+.icon-bell:before {
+ content: "\e903";
+}
+.icon-x:before {
+ content: "\e904";
+}
+.icon-quote-close:before {
+ content: "\e905";
+}
+.icon-quote-open:before {
+ content: "\e906";
+}
+.icon-image:before {
+ content: "\e907";
+}
+.icon-pencil:before {
+ content: "\e908";
+}
+.icon-list:before {
+ content: "\e909";
+}
+.icon-unlock:before {
+ content: "\e90a";
+}
+.icon-unlisted:before {
+ content: "\e90a";
+}
+.icon-globe:before {
+ content: "\e90b";
+}
+.icon-public:before {
+ content: "\e90b";
+}
+.icon-lock:before {
+ content: "\e90c";
+}
+.icon-followers:before {
+ content: "\e90c";
+}
+.icon-chain-broken:before {
+ content: "\e90d";
+}
+.icon-chain:before {
+ content: "\e90e";
+}
+.icon-comments:before {
+ content: "\e90f";
+}
+.icon-comment:before {
+ content: "\e910";
+}
+.icon-boost:before {
+ content: "\e911";
+}
+.icon-arrow-left:before {
+ content: "\e912";
+}
+.icon-arrow-up:before {
+ content: "\e913";
+}
+.icon-arrow-down:before {
+ content: "\e914";
+}
+.icon-home:before {
+ content: "\e915";
+}
+.icon-local:before {
+ content: "\e916";
+}
+.icon-dots-three:before {
+ content: "\e917";
+}
+.icon-check:before {
+ content: "\e918";
+}
+.icon-dots-three-vertical:before {
+ content: "\e919";
+}
+.icon-search:before {
+ content: "\e986";
+}
+.icon-star-empty:before {
+ content: "\e9d7";
+}
+.icon-star-half:before {
+ content: "\e9d8";
+}
+.icon-star-full:before {
+ content: "\e9d9";
+}
+.icon-heart:before {
+ content: "\e9da";
+}
+.icon-plus:before {
+ content: "\ea0a";
+}
diff --git a/bookwyrm/static/images/logo-small.png b/bookwyrm/static/images/logo-small.png
index 10ea7a38..72f49ef7 100644
Binary files a/bookwyrm/static/images/logo-small.png and b/bookwyrm/static/images/logo-small.png differ
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
new file mode 100644
index 00000000..e43ed134
--- /dev/null
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -0,0 +1,325 @@
+/* exported BookWyrm */
+/* globals TabGroup */
+
+let BookWyrm = new class {
+ constructor() {
+ this.MAX_FILE_SIZE_BYTES = 10 * 1000000;
+ 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)
+ );
+
+ document.querySelectorAll('input[type="file"]')
+ .forEach(node => node.addEventListener(
+ 'change',
+ this.disableIfTooLarge.bind(this)
+ ));
+ }
+
+ /**
+ * Execute code once the DOM is loaded.
+ */
+ initOnDOMLoaded() {
+ const bookwyrm = this;
+
+ window.addEventListener('DOMContentLoaded', function() {
+ document.querySelectorAll('.tab-group')
+ .forEach(tabs => new TabGroup(tabs));
+ document.querySelectorAll('input[type="file"]').forEach(
+ bookwyrm.disableIfTooLarge.bind(bookwyrm)
+ );
+ });
+ }
+
+ /**
+ * 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;
+ const hasMentions = data.has_mentions;
+
+ if (count != currentCount) {
+ this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
+ counter.innerText = count;
+ this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-danger', hasMentions);
+ }
+ }
+
+ /**
+ * 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) {
+ event.preventDefault();
+ 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);
+ }
+
+ return false;
+ }
+
+ /**
+ * 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);
+ }
+ }
+
+ disableIfTooLarge(eventOrElement) {
+ const { addRemoveClass, MAX_FILE_SIZE_BYTES } = this;
+ const element = eventOrElement.currentTarget || eventOrElement;
+
+ const submits = element.form.querySelectorAll('[type="submit"]');
+ const warns = element.parentElement.querySelectorAll('.file-too-big');
+ const isTooBig = element.files &&
+ element.files[0] &&
+ element.files[0].size > MAX_FILE_SIZE_BYTES;
+
+ if (isTooBig) {
+ submits.forEach(submitter => submitter.disabled = true);
+ warns.forEach(
+ sib => addRemoveClass(sib, 'is-hidden', false)
+ );
+ } else {
+ submits.forEach(submitter => submitter.disabled = false);
+ warns.forEach(
+ sib => addRemoveClass(sib, 'is-hidden', true)
+ );
+ }
+ }
+}();
diff --git a/bookwyrm/static/js/check_all.js b/bookwyrm/static/js/check_all.js
new file mode 100644
index 00000000..fd29f2cd
--- /dev/null
+++ b/bookwyrm/static/js/check_all.js
@@ -0,0 +1,34 @@
+
+(function() {
+ 'use strict';
+
+ /**
+ * Toggle all descendant checkboxes of a target.
+ *
+ * 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.
+ *
+ * @example
+ *
+ * @param {Event} event
+ * @return {undefined}
+ */
+ function toggleAllCheckboxes(event) {
+ const mainCheckbox = event.target;
+
+ document
+ .querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
+ .forEach(checkbox => checkbox.checked = mainCheckbox.checked);
+ }
+
+ document
+ .querySelectorAll('[data-action="toggle-all"]')
+ .forEach(input => {
+ input.addEventListener('change', toggleAllCheckboxes);
+ });
+})();
diff --git a/bookwyrm/static/js/localstorage.js b/bookwyrm/static/js/localstorage.js
new file mode 100644
index 00000000..b485ed7e
--- /dev/null
+++ b/bookwyrm/static/js/localstorage.js
@@ -0,0 +1,43 @@
+/* exported LocalStorageTools */
+/* globals BookWyrm */
+
+let LocalStorageTools = new class {
+ 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
+ let key = event.target.dataset.id;
+ let value = event.target.dataset.value;
+
+ window.localStorage.setItem(key, value);
+
+ document.querySelectorAll('[data-hide="' + key + '"]')
+ .forEach(node => this.setDisplay(node));
+ }
+
+ /**
+ * Toggle display of a DOM node based on its value in the localStorage.
+ *
+ * @param {object} node - DOM node to toggle.
+ * @return {undefined}
+ */
+ setDisplay(node) {
+ // Used in set reading goal
+ let key = node.dataset.hide;
+ let value = window.localStorage.getItem(key);
+
+ BookWyrm.addRemoveClass(node, 'is-hidden', value);
+ }
+}();
diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js
deleted file mode 100644
index 758b76dc..00000000
--- a/bookwyrm/static/js/shared.js
+++ /dev/null
@@ -1,205 +0,0 @@
-// 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);
-
- // select all
- Array.from(document.getElementsByClassName('select-all'))
- .forEach(t => t.onclick = selectAll);
-
- // toggle between tabs
- Array.from(document.getElementsByClassName('tab-change'))
- .forEach(t => t.onclick = tabChange);
-
- // handle aria settings on menus
- Array.from(document.getElementsByClassName('pulldown-menu'))
- .forEach(t => t.onclick = toggleMenu);
-
- // 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);
-
- // 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);
-};
-
-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[el.getAttribute('data-poll')];
- if (count != currentCount) {
- addRemoveClass(el, '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 updateDisplay(e) {
- // used in set reading goal
- var key = e.target.getAttribute('data-id');
- var value = e.target.getAttribute('data-value');
- window.localStorage.setItem(key, value);
-
- document.querySelectorAll('[data-hide="' + key + '"]')
- .forEach(t => setDisplay(t));
-}
-
-function setDisplay(el) {
- // used in set reading goal
- var key = el.getAttribute('data-hide');
- var value = window.localStorage.getItem(key);
- addRemoveClass(el, 'hidden', value);
-}
-
-
-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 selectAll(e) {
- e.target.parentElement.parentElement.querySelectorAll('[type="checkbox"]')
- .forEach(t => t.checked=true);
-}
-
-function tabChange(e) {
- var el = e.currentTarget;
- var parentElement = el.closest('[role="tablist"]');
-
- parentElement.querySelectorAll('[aria-selected="true"]')
- .forEach(t => t.setAttribute("aria-selected", false));
- el.setAttribute("aria-selected", true);
-
- parentElement.querySelectorAll('li')
- .forEach(t => removeClass(t, 'is-active'));
- addClass(el, 'is-active');
-
- var tabId = el.getAttribute('data-tab');
- Array.from(document.getElementsByClassName(el.getAttribute('data-category')))
- .forEach(t => addRemoveClass(t, 'hidden', t.id != tabId));
-}
-
-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(' ');
-}
diff --git a/bookwyrm/static/js/vendor/tabs.js b/bookwyrm/static/js/vendor/tabs.js
new file mode 100644
index 00000000..f9568b29
--- /dev/null
+++ b/bookwyrm/static/js/vendor/tabs.js
@@ -0,0 +1,258 @@
+/* exported TabGroup */
+
+/*
+* The content below is licensed according to the W3C Software License at
+* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
+* Heavily modified to web component by Zach Leatherman
+* Modified back to vanilla JavaScript with support for Bulma markup and nested tabs by Ned Zimmerman
+*/
+class TabGroup {
+ constructor(container) {
+ this.container = container;
+
+ this.tablist = this.container.querySelector('[role="tablist"]');
+ this.buttons = this.tablist.querySelectorAll('[role="tab"]');
+ this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]');
+ this.delay = this.determineDelay();
+
+ if(!this.tablist || !this.buttons.length || !this.panels.length) {
+ return;
+ }
+
+ this.keys = this.keys();
+ this.direction = this.direction();
+ this.initButtons();
+ this.initPanels();
+ }
+
+ keys() {
+ return {
+ end: 35,
+ home: 36,
+ left: 37,
+ up: 38,
+ right: 39,
+ down: 40
+ };
+ }
+
+ // Add or substract depending on key pressed
+ direction() {
+ return {
+ 37: -1,
+ 38: -1,
+ 39: 1,
+ 40: 1
+ };
+ }
+
+ initButtons() {
+ let count = 0;
+ for(let button of this.buttons) {
+ let isSelected = button.getAttribute("aria-selected") === "true";
+ button.setAttribute("tabindex", isSelected ? "0" : "-1");
+
+ button.addEventListener('click', this.clickEventListener.bind(this));
+ button.addEventListener('keydown', this.keydownEventListener.bind(this));
+ button.addEventListener('keyup', this.keyupEventListener.bind(this));
+
+ button.index = count++;
+ }
+ }
+
+ initPanels() {
+ let selectedPanelId = this.tablist
+ .querySelector('[role="tab"][aria-selected="true"]')
+ .getAttribute("aria-controls");
+ for(let panel of this.panels) {
+ if(panel.getAttribute("id") !== selectedPanelId) {
+ panel.setAttribute("hidden", "");
+ }
+ panel.setAttribute("tabindex", "0");
+ }
+ }
+
+ clickEventListener(event) {
+ let button = event.target.closest('a');
+
+ event.preventDefault();
+
+ this.activateTab(button, false);
+ }
+
+ // Handle keydown on tabs
+ keydownEventListener(event) {
+ var key = event.keyCode;
+
+ switch (key) {
+ case this.keys.end:
+ event.preventDefault();
+ // Activate last tab
+ this.activateTab(this.buttons[this.buttons.length - 1]);
+ break;
+ case this.keys.home:
+ event.preventDefault();
+ // Activate first tab
+ this.activateTab(this.buttons[0]);
+ break;
+
+ // Up and down are in keydown
+ // because we need to prevent page scroll >:)
+ case this.keys.up:
+ case this.keys.down:
+ this.determineOrientation(event);
+ break;
+ }
+ }
+
+ // Handle keyup on tabs
+ keyupEventListener(event) {
+ var key = event.keyCode;
+
+ switch (key) {
+ case this.keys.left:
+ case this.keys.right:
+ this.determineOrientation(event);
+ break;
+ }
+ }
+
+ // When a tablist’s aria-orientation is set to vertical,
+ // only up and down arrow should function.
+ // In all other cases only left and right arrow function.
+ determineOrientation(event) {
+ var key = event.keyCode;
+ var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical';
+ var proceed = false;
+
+ if (vertical) {
+ if (key === this.keys.up || key === this.keys.down) {
+ event.preventDefault();
+ proceed = true;
+ }
+ }
+ else {
+ if (key === this.keys.left || key === this.keys.right) {
+ proceed = true;
+ }
+ }
+
+ if (proceed) {
+ this.switchTabOnArrowPress(event);
+ }
+ }
+
+ // Either focus the next, previous, first, or last tab
+ // depending on key pressed
+ switchTabOnArrowPress(event) {
+ var pressed = event.keyCode;
+
+ for (let button of this.buttons) {
+ button.addEventListener('focus', this.focusEventHandler.bind(this));
+ }
+
+ if (this.direction[pressed]) {
+ var target = event.target;
+ if (target.index !== undefined) {
+ if (this.buttons[target.index + this.direction[pressed]]) {
+ this.buttons[target.index + this.direction[pressed]].focus();
+ }
+ else if (pressed === this.keys.left || pressed === this.keys.up) {
+ this.focusLastTab();
+ }
+ else if (pressed === this.keys.right || pressed == this.keys.down) {
+ this.focusFirstTab();
+ }
+ }
+ }
+ }
+
+ // Activates any given tab panel
+ activateTab (tab, setFocus) {
+ if(tab.getAttribute("role") !== "tab") {
+ tab = tab.closest('[role="tab"]');
+ }
+
+ setFocus = setFocus || true;
+
+ // Deactivate all other tabs
+ this.deactivateTabs();
+
+ // Remove tabindex attribute
+ tab.removeAttribute('tabindex');
+
+ // Set the tab as selected
+ tab.setAttribute('aria-selected', 'true');
+
+ // Give the tab parent an is-active class
+ tab.parentNode.classList.add('is-active');
+
+ // Get the value of aria-controls (which is an ID)
+ var controls = tab.getAttribute('aria-controls');
+
+ // Remove hidden attribute from tab panel to make it visible
+ document.getElementById(controls).removeAttribute('hidden');
+
+ // Set focus when required
+ if (setFocus) {
+ tab.focus();
+ }
+ }
+
+ // Deactivate all tabs and tab panels
+ deactivateTabs() {
+ for (let button of this.buttons) {
+ button.parentNode.classList.remove('is-active');
+ button.setAttribute('tabindex', '-1');
+ button.setAttribute('aria-selected', 'false');
+ button.removeEventListener('focus', this.focusEventHandler.bind(this));
+ }
+
+ for (let panel of this.panels) {
+ panel.setAttribute('hidden', 'hidden');
+ }
+ }
+
+ focusFirstTab() {
+ this.buttons[0].focus();
+ }
+
+ focusLastTab() {
+ this.buttons[this.buttons.length - 1].focus();
+ }
+
+ // Determine whether there should be a delay
+ // when user navigates with the arrow keys
+ determineDelay() {
+ var hasDelay = this.tablist.hasAttribute('data-delay');
+ var delay = 0;
+
+ if (hasDelay) {
+ var delayValue = this.tablist.getAttribute('data-delay');
+ if (delayValue) {
+ delay = delayValue;
+ }
+ else {
+ // If no value is specified, default to 300ms
+ delay = 300;
+ }
+ }
+
+ return delay;
+ }
+
+ focusEventHandler(event) {
+ var target = event.target;
+
+ setTimeout(this.checkTabFocus.bind(this), this.delay, target);
+ }
+
+ // Only activate tab on focus if it still has focus after the delay
+ checkTabFocus(target) {
+ let focused = document.activeElement;
+
+ if (target === focused) {
+ this.activateTab(target, false);
+ }
+ }
+}
diff --git a/bookwyrm/status.py b/bookwyrm/status.py
index 4dc4991d..09fbdc06 100644
--- a/bookwyrm/status.py
+++ b/bookwyrm/status.py
@@ -1,20 +1,12 @@
-''' Handle user activity '''
+""" Handle user activity """
from django.db import transaction
-from django.utils import timezone
from bookwyrm import models
from bookwyrm.sanitize_html import InputHtmlParser
-def delete_status(status):
- ''' replace the status with a tombstone '''
- status.deleted = True
- status.deleted_date = timezone.now()
- status.save()
-
-
-def create_generated_note(user, content, mention_books=None, privacy='public'):
- ''' a note created by the app about user activity '''
+def create_generated_note(user, content, mention_books=None, privacy="public"):
+ """a note created by the app about user activity"""
# sanitize input html
parser = InputHtmlParser()
parser.feed(content)
@@ -22,11 +14,7 @@ def create_generated_note(user, content, mention_books=None, privacy='public'):
with transaction.atomic():
# create but don't save
- status = models.GeneratedNote(
- user=user,
- content=content,
- privacy=privacy
- )
+ status = models.GeneratedNote(user=user, content=content, privacy=privacy)
# we have to save it to set the related fields, but hold off on telling
# folks about it because it is not ready
status.save(broadcast=False)
diff --git a/bookwyrm/tasks.py b/bookwyrm/tasks.py
index fc0b9739..23765f09 100644
--- a/bookwyrm/tasks.py
+++ b/bookwyrm/tasks.py
@@ -1,12 +1,12 @@
-''' background tasks '''
+""" background tasks """
import os
from celery import Celery
from bookwyrm import settings
# set the default Django settings module for the 'celery' program.
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'celerywyrm.settings')
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "celerywyrm.settings")
app = Celery(
- 'tasks',
+ "tasks",
broker=settings.CELERY_BROKER,
)
diff --git a/bookwyrm/templates/404.html b/bookwyrm/templates/404.html
new file mode 100644
index 00000000..210e1a13
--- /dev/null
+++ b/bookwyrm/templates/404.html
@@ -0,0 +1,11 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+
+{% block title %}{% trans "Not Found" %}{% endblock %}
+
+{% block content %}
+
+
{% trans "Not Found" %}
+
{% trans "The page you requested doesn't seem to exist!" %}
+
+{% endblock %}
diff --git a/bookwyrm/templates/500.html b/bookwyrm/templates/500.html
new file mode 100644
index 00000000..ce448287
--- /dev/null
+++ b/bookwyrm/templates/500.html
@@ -0,0 +1,12 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+
+{% block title %}{% trans "Oops!" %}{% endblock %}
+
+{% block content %}
+
+
{% trans "Server Error" %}
+
{% trans "Something went wrong! Sorry about that." %}
+
+{% endblock %}
+
diff --git a/bookwyrm/templates/about.html b/bookwyrm/templates/about.html
deleted file mode 100644
index aa7426ca..00000000
--- a/bookwyrm/templates/about.html
+++ /dev/null
@@ -1,16 +0,0 @@
-{% extends 'layout.html' %}
-{% block content %}
-
-
-
- {% include 'snippets/about.html' %}
-
-
-
-
Code of Conduct
-
- {{ site.code_of_conduct | safe }}
-
-
-
-{% endblock %}
diff --git a/bookwyrm/templates/author.html b/bookwyrm/templates/author.html
deleted file mode 100644
index a875ad78..00000000
--- a/bookwyrm/templates/author.html
+++ /dev/null
@@ -1,37 +0,0 @@
-{% extends 'layout.html' %}
-{% load bookwyrm_tags %}
-{% block content %}
-
-
-
-
{{ author.name }}
-
- {% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
-
- {% endif %}
-
-
-
-
- {% if author.bio %}
-
- {{ author.bio | to_markdown | safe }}
-
- {% endif %}
- {% if author.wikipedia_link %}
-
Wikipedia
- {% endif %}
-
-
-
-
Books by {{ author.name }}
- {% include 'snippets/book_tiles.html' with books=books %}
-
-{% endblock %}
-
diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html
new file mode 100644
index 00000000..0bc42775
--- /dev/null
+++ b/bookwyrm/templates/author/author.html
@@ -0,0 +1,117 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load markdown %}
+{% load humanize %}
+
+{% block title %}{{ author.name }}{% endblock %}
+
+{% block content %}
+
+
+
+
{{ author.name }}
+
+ {% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
+
+ {% endif %}
+
+
+
+
+
+
+ {% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %}
+
+ {% endif %}
+
+ {% if author.bio %}
+ {{ author.bio|to_markdown|safe }}
+ {% endif %}
+
+
+
+
+
{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}
+
+ {% for book in books %}
+
+ {% include 'discover/small-book.html' with book=book %}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/author/edit_author.html b/bookwyrm/templates/author/edit_author.html
new file mode 100644
index 00000000..103341bf
--- /dev/null
+++ b/bookwyrm/templates/author/edit_author.html
@@ -0,0 +1,121 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load humanize %}
+
+{% block title %}{% trans "Edit Author:" %} {{ author.name }}{% endblock %}
+
+{% block content %}
+
+
+{% if form.non_field_errors %}
+
+
{{ form.non_field_errors }}
+
+{% endif %}
+
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html
deleted file mode 100644
index c174116e..00000000
--- a/bookwyrm/templates/book.html
+++ /dev/null
@@ -1,265 +0,0 @@
-{% extends 'layout.html' %}
-{% load bookwyrm_tags %}
-{% load humanize %}
-{% block content %}
-
-
-
-
-
- {{ book.title }}{% if book.subtitle %}:
- {{ book.subtitle }} {% endif %}
- {% if book.series %}
- ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})
- {% endif %}
-
- {% if book.authors %}
-
- by {% include 'snippets/authors.html' with book=book %}
-
- {% endif %}
-
-
- {% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
-
- {% endif %}
-
-
-
-
- {% include 'snippets/book_cover.html' with book=book size=large %}
- {% include 'snippets/rate_action.html' with user=request.user book=book %}
- {% include 'snippets/shelve_button/shelve_button.html' %}
-
- {% if request.user.is_authenticated and not book.cover %}
-
- {% endif %}
-
-
-
- {% if book.isbn_13 %}
-
-
ISBN:
- {{ book.isbn_13 }}
-
- {% endif %}
-
- {% if book.oclc_number %}
-
-
OCLC Number:
- {{ book.oclc_number }}
-
- {% endif %}
-
- {% if book.asin %}
-
-
ASIN:
- {{ book.asin }}
-
- {% endif %}
-
-
-
- {% if book.physical_format %}{{ book.physical_format | title }}{% if book.pages %}, {% endif %}{% endif %}
- {% if book.pages %}{{ book.pages }} pages{% endif %}
-
-
- {% if book.openlibrary_key %}
- View on OpenLibrary
- {% endif %}
-
-
-
-
-
-
{% include 'snippets/stars.html' with rating=rating %} ({{ review_count }} review{{ review_count|pluralize }})
-
- {% include 'snippets/trimmed_text.html' with full=book|book_description %}
-
- {% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
- {% include 'snippets/toggle/open_button.html' with text="Add description" controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
-
-
- {% endif %}
-
-
- {% if book.parent_work.editions.count > 1 %}
-
{{ book.parent_work.editions.count }} editions
- {% endif %}
-
-
- {# user's relationship to the book #}
-
- {% for shelf in user_shelves %}
-
- This edition is on your {{ shelf.shelf.name }} shelf.
- {% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
-
- {% endfor %}
- {% for shelf in other_edition_shelves %}
-
- A different edition of this book is on your {{ shelf.shelf.name }} shelf.
- {% include 'snippets/switch_edition_button.html' with edition=book %}
-
- {% endfor %}
-
-
- {% if request.user.is_authenticated %}
-
-
- {% if not readthroughs.exists %}
- You don't have any reading activity for this book.
- {% endif %}
-
-
- {% include 'snippets/readthrough_form.html' with readthrough=None %}
-
-
- Create
-
-
- {% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="add-readthrough" %}
-
-
-
-
- {% for readthrough in readthroughs %}
- {% include 'snippets/readthrough.html' with readthrough=readthrough %}
- {% endfor %}
-
- {% endif %}
-
- {% if request.user.is_authenticated %}
-
- {% include 'snippets/create_status.html' with book=book hide_cover=True %}
-
-
-
- {% endif %}
-
-
-
- {% for tag in tags %}
- {% include 'snippets/tag.html' with book=book tag=tag user_tags=user_tags %}
- {% endfor %}
-
-
-
-
-
- {% if book.subjects %}
-
- Subjects
-
- {% for subject in book.subjects %}
- {{ subject }}
- {% endfor %}
-
-
- {% endif %}
-
- {% if book.subject_places %}
-
- Places
-
- {% for place in book.subject_placess %}
- {{ place }}
- {% endfor %}
-
-
- {% endif %}
-
-
-
-
-
- {% for review in reviews %}
-
- {% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
-
- {% endfor %}
-
-
- {% for rating in ratings %}
-
- {% endfor %}
-
-
- {% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
-
-
-
-{% endblock %}
-
diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html
new file mode 100644
index 00000000..dd5a4ff3
--- /dev/null
+++ b/bookwyrm/templates/book/book.html
@@ -0,0 +1,312 @@
+{% extends 'layout.html' %}
+{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}
+
+{% block title %}{{ book|book_title }}{% endblock %}
+
+{% block content %}
+{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
+
+
+
+
+
+ {{ book.title }}{% if book.subtitle %}:
+ {{ book.subtitle }}
+ {% endif %}
+
+
+ {% if book.series %}
+
+
+
+
+ ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})
+
+
+ {% endif %}
+
+ {% if book.authors %}
+
+ {% trans "by" %} {% include 'snippets/authors.html' with book=book %}
+
+ {% endif %}
+
+
+ {% if user_authenticated and can_edit_book %}
+
+ {% endif %}
+
+
+
+
+ {% include 'snippets/book_cover.html' with book=book cover_class='is-h-m-mobile' %}
+ {% include 'snippets/rate_action.html' with user=request.user book=book %}
+
+
+ {% include 'snippets/shelve_button/shelve_button.html' %}
+
+
+ {% if user_authenticated and not book.cover %}
+
+ {% 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 'book/cover_modal.html' with book=book controls_text="add-cover" controls_uid=book.id %}
+ {% if request.GET.cover_error %}
+
{% trans "Failed to load cover" %}
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ {# @todo Is it possible to not hard-code the value? #}
+
+
+
+ {% include 'snippets/stars.html' with rating=rating %}
+
+ {% blocktrans count counter=review_count trimmed %}
+ ({{ review_count }} review)
+ {% plural %}
+ ({{ review_count }} reviews)
+ {% endblocktrans %}
+
+
+ {% with full=book|book_description itemprop='abstract' %}
+ {% include 'snippets/trimmed_text.html' %}
+ {% endwith %}
+
+ {% if user_authenticated and can_edit_book and not book|book_description %}
+ {% 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" %}
+
+
+
+ {% csrf_token %}
+
+ {% trans "Description:" %}
+
+
+
+ {% trans "Save" %}
+ {% trans "Cancel" as button_text %}
+ {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="add-description" controls_uid=book.id hide_inactive=True %}
+
+
+
+ {% endif %}
+
+
+ {% if book.parent_work.editions.count > 1 %}
+
{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}{{ count }} editions {% endblocktrans %}
+ {% endif %}
+
+
+ {# user's relationship to the book #}
+
+ {% for shelf in user_shelfbooks %}
+
+ {% blocktrans with path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}This edition is on your {{ shelf_name }} shelf.{% endblocktrans %}
+ {% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
+
+ {% endfor %}
+ {% for shelf in other_edition_shelves %}
+
+ {% blocktrans with book_path=shelf.book.local_path shelf_path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}A different edition of this book is on your {{ shelf_name }} shelf.{% endblocktrans %}
+ {% include 'snippets/switch_edition_button.html' with edition=book %}
+
+ {% endfor %}
+
+
+ {% if user_authenticated %}
+
+
+
+
+
{% trans "Your reading activity" %}
+
+
+ {% trans "Add read dates" as button_text %}
+ {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add-readthrough" focus="add-readthrough-focus" %}
+
+
+
+
+ {% include 'snippets/readthrough_form.html' with readthrough=None %}
+
+
+ {% trans "Create" %}
+
+
+ {% trans "Cancel" as button_text %}
+ {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="add-readthrough" %}
+
+
+
+
+ {% if not readthroughs.exists %}
+ {% trans "You don't have any reading activity for this book." %}
+ {% endif %}
+ {% for readthrough in readthroughs %}
+ {% include 'book/readthrough.html' with readthrough=readthrough %}
+ {% endfor %}
+
+
+
+
+ {% include 'snippets/create_status.html' with book=book hide_cover=True %}
+
+ {% endif %}
+
+ {% if request.user.is_authenticated %}
+ {% if user_statuses.review_count or user_statuses.comment_count or user_statuses.quotation_count %}
+
+
+
+ {% endif %}
+ {% endif %}
+
+ {% for status in statuses %}
+
+ {% include 'snippets/status/status.html' with status=status hide_book=True depth=1 %}
+
+ {% endfor %}
+
+ {% if ratings %}
+
+ {% for rating in ratings %}
+ {% include 'book/rating.html' with user=rating.user rating=rating %}
+ {% endfor %}
+
+ {% endif %}
+
+ {% include 'snippets/pagination.html' with page=statuses path=request.path anchor="#reviews" %}
+
+
+
+
+ {% if book.subjects %}
+
+ {% trans "Subjects" %}
+
+
+ {% for subject in book.subjects %}
+ {{ subject }}
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if book.subject_places %}
+
+ {% trans "Places" %}
+
+ {% for place in book.subject_places %}
+ {{ place }}
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if lists.exists or request.user.list_set.exists %}
+
+ {% trans "Lists" %}
+
+
+ {% if request.user.list_set.exists %}
+
+ {% csrf_token %}
+
+ {% trans "Add to list" %}
+
+
+
+ {% for list in user.list_set.all %}
+ {{ list.name }}
+ {% endfor %}
+
+
+
+ {% trans "Add" %}
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+
+
+
+{% endwith %}
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/bookwyrm/templates/book/book_identifiers.html b/bookwyrm/templates/book/book_identifiers.html
new file mode 100644
index 00000000..6021d243
--- /dev/null
+++ b/bookwyrm/templates/book/book_identifiers.html
@@ -0,0 +1,26 @@
+{% spaceless %}
+{% load i18n %}
+
+
+ {% if book.isbn_13 %}
+
+
{% trans "ISBN:" %}
+ {{ book.isbn_13 }}
+
+ {% endif %}
+
+ {% if book.oclc_number %}
+
+
{% trans "OCLC Number:" %}
+ {{ book.oclc_number }}
+
+ {% endif %}
+
+ {% if book.asin %}
+
+
{% trans "ASIN:" %}
+ {{ book.asin }}
+
+ {% endif %}
+
+{% endspaceless %}
diff --git a/bookwyrm/templates/book/cover_modal.html b/bookwyrm/templates/book/cover_modal.html
new file mode 100644
index 00000000..f09b4495
--- /dev/null
+++ b/bookwyrm/templates/book/cover_modal.html
@@ -0,0 +1,36 @@
+{% extends 'components/modal.html' %}
+{% load i18n %}
+
+{% block modal-title %}
+{% trans "Add cover" %}
+{% endblock %}
+
+{% block modal-form-open %}
+
+{% endblock %}
+
+{% block modal-body %}
+
+{% endblock %}
+
+{% block modal-footer %}
+{% trans "Add" %}
+{% trans "Cancel" as button_text %}
+{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
+{% endblock %}
+{% block modal-form-close %} {% endblock %}
+
diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit_book.html
new file mode 100644
index 00000000..32018a25
--- /dev/null
+++ b/bookwyrm/templates/book/edit_book.html
@@ -0,0 +1,327 @@
+{% extends 'layout.html' %}
+{% load i18n %}
+{% load humanize %}
+
+{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
+
+{% block content %}
+
+
+{% if form.non_field_errors %}
+
+
{{ form.non_field_errors }}
+
+{% endif %}
+
+{% if book %}
+
+{% else %}
+
+{% endif %}
+
+ {% csrf_token %}
+ {% if confirm_mode %}
+
+
{% trans "Confirm Book Info" %}
+
+ {% if author_matches %}
+
+
+ {% else %}
+
{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}
+ {% endif %}
+
+ {% if not book %}
+
+
+
+ {% trans "Is this an edition of an existing work?" %}
+
+ {% for match in book_matches %}
+
+ {{ match.parent_work.title }}
+
+ {% endfor %}
+
+ {% trans "This is a new work" %}
+
+
+
+ {% endif %}
+
+
+
{% trans "Confirm" %}
+
+ {% trans "Back" %}
+
+
+
+
+ {% endif %}
+
+
+
+
+
+ {% trans "Metadata" %}
+
+
+
{% trans "Title:" %}
+
+ {% for error in form.title.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Subtitle:" %}
+
+ {% for error in form.subtitle.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Description:" %}
+ {{ form.description }}
+ {% for error in form.description.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Series:" %}
+
+ {% for error in form.series.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Series number:" %}
+ {{ form.series_number }}
+ {% for error in form.series_number.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Languages:" %}
+ {{ form.languages }}
+
{% trans "Separate multiple values with commas." %}
+ {% for error in form.languages.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Publisher:" %}
+ {{ form.publishers }}
+
{% trans "Separate multiple values with commas." %}
+ {% for error in form.publishers.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "First published date:" %}
+
+ {% for error in form.first_published_date.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Published date:" %}
+
+ {% for error in form.published_date.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
+
+
+
+
{% trans "Cover" %}
+
+
+ {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' %}
+
+
+
+
+
+ {% trans "Upload cover:" %}
+ {{ form.cover }}
+
+ {% if book %}
+
+
+ {% trans "Load cover from url:" %}
+
+
+
+ {% endif %}
+ {% for error in form.cover.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
+
+
{% trans "Physical Properties" %}
+
+
{% trans "Format:" %}
+ {{ form.physical_format }}
+ {% for error in form.physical_format.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Pages:" %}
+ {{ form.pages }}
+ {% for error in form.pages.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
+
{% trans "Book Identifiers" %}
+
+
{% trans "ISBN 13:" %}
+ {{ form.isbn_13 }}
+ {% for error in form.isbn_13.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "ISBN 10:" %}
+ {{ form.isbn_10 }}
+ {% for error in form.isbn_10.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Openlibrary ID:" %}
+ {{ form.openlibrary_key }}
+ {% for error in form.openlibrary_key.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "Inventaire ID:" %}
+ {{ form.inventaire_id }}
+ {% for error in form.inventaire_id.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "OCLC Number:" %}
+ {{ form.oclc_number }}
+ {% for error in form.oclc_number.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
{% trans "ASIN:" %}
+ {{ form.asin }}
+ {% for error in form.ASIN.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
+
+
+
+
+ {% if not confirm_mode %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/book/edition_filters.html b/bookwyrm/templates/book/edition_filters.html
new file mode 100644
index 00000000..a55b72af
--- /dev/null
+++ b/bookwyrm/templates/book/edition_filters.html
@@ -0,0 +1,6 @@
+{% extends 'snippets/filters_panel/filters_panel.html' %}
+
+{% block filter_fields %}
+{% include 'book/language_filter.html' %}
+{% include 'book/format_filter.html' %}
+{% endblock %}
diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions.html
new file mode 100644
index 00000000..e2a0bdda
--- /dev/null
+++ b/bookwyrm/templates/book/editions.html
@@ -0,0 +1,52 @@
+{% extends 'layout.html' %}
+{% load i18n %}{% load utilities %}
+
+{% block title %}{% blocktrans with book_title=work|book_title %}Editions of {{ book_title }}{% endblocktrans %}{% endblock %}
+
+{% block content %}
+
+
{% blocktrans with work_path=work.local_path work_title=work|book_title %}Editions of "{{ work_title }}" {% endblocktrans %}
+
+
+{% include 'book/edition_filters.html' %}
+
+
+ {% for book in editions %}
+
+
+
+
+
+
+ {% with book=book %}
+
+
+ {% include 'book/publisher_info.html' %}
+
+
+
+ {% include 'book/book_identifiers.html' %}
+
+
+ {% endwith %}
+
+
+
+ {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True right=True %}
+
+
+ {% endfor %}
+
+
+
+ {% include 'snippets/pagination.html' with page=editions path=request.path %}
+
+{% endblock %}
diff --git a/bookwyrm/templates/book/format_filter.html b/bookwyrm/templates/book/format_filter.html
new file mode 100644
index 00000000..c722b24f
--- /dev/null
+++ b/bookwyrm/templates/book/format_filter.html
@@ -0,0 +1,16 @@
+{% extends 'snippets/filters_panel/filter_field.html' %}
+{% load i18n %}
+
+{% block filter %}
+{% trans "Format:" %}
+
+
+ {% trans "Any" %}
+ {% for format in formats %}{% if format %}
+
+ {{ format|title }}
+
+ {% endif %}{% endfor %}
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/book/language_filter.html b/bookwyrm/templates/book/language_filter.html
new file mode 100644
index 00000000..d9051fd8
--- /dev/null
+++ b/bookwyrm/templates/book/language_filter.html
@@ -0,0 +1,16 @@
+{% extends 'snippets/filters_panel/filter_field.html' %}
+{% load i18n %}
+
+{% block filter %}
+{% trans "Language:" %}
+
+
+ {% trans "Any" %}
+ {% for language in languages %}
+
+ {{ language }}
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html
new file mode 100644
index 00000000..b7975a62
--- /dev/null
+++ b/bookwyrm/templates/book/publisher_info.html
@@ -0,0 +1,70 @@
+{% spaceless %}
+
+{% load i18n %}
+{% load humanize %}
+
+
+ {% with format=book.physical_format pages=book.pages %}
+ {% if format %}
+ {% comment %}
+ @todo The bookFormat property is limited to a list of values whereas the book edition is free text.
+ @see https://schema.org/bookFormat
+ {% endcomment %}
+
+ {% endif %}
+
+ {% if 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 %}
+
+
+{% if book.languages %}
+ {% for language in book.languages %}
+
+ {% endfor %}
+
+
+ {% with languages=book.languages|join:", " %}
+ {% blocktrans %}{{ languages }} language{% endblocktrans %}
+ {% endwith %}
+
+{% endif %}
+
+
+ {% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
+ {% if date or book.first_published_date %}
+
+ {% 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 %}
+
+ {% 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 %}
+
+{% endspaceless %}
diff --git a/bookwyrm/templates/book/rating.html b/bookwyrm/templates/book/rating.html
new file mode 100644
index 00000000..c0af67fa
--- /dev/null
+++ b/bookwyrm/templates/book/rating.html
@@ -0,0 +1,22 @@
+{% load i18n %}{% load status_display %}
+
diff --git a/bookwyrm/templates/snippets/readthrough.html b/bookwyrm/templates/book/readthrough.html
similarity index 53%
rename from bookwyrm/templates/snippets/readthrough.html
rename to bookwyrm/templates/book/readthrough.html
index 7e51ce97..75140746 100644
--- a/bookwyrm/templates/snippets/readthrough.html
+++ b/bookwyrm/templates/book/readthrough.html
@@ -1,31 +1,44 @@
+{% load i18n %}
{% load humanize %}
-
-
+{% load tz %}
+
+
- Progress Updates:
+ {% trans "Progress Updates:" %}
{% if readthrough.finish_date or readthrough.progress %}
- {% if readthrough.finish_date %} {{ readthrough.finish_date | naturalday }}: finished {% else %}{% if readthrough.progress_mode == 'PG' %}on page {{ readthrough.progress }}{% if book.pages %} of {{ book.pages }}{% endif %}
- {% else %}{{ readthrough.progress }}%{% endif %}{% endif %}
+
+ {% if readthrough.finish_date %}
+ {{ readthrough.finish_date | localtime | naturalday }}: {% trans "finished" %}
+ {% else %}
+
+ {% if readthrough.progress_mode == 'PG' %}
+ {% include 'snippets/page_text.html' with page=readthrough.progress total_pages=book.pages %}
+ {% else %}
+ {{ readthrough.progress }}%
+ {% endif %}
+ {% endif %}
+
{% if readthrough.progress %}
- {% include 'snippets/toggle/toggle_button.html' with text="Show all updates" controls_text="updates" controls_uid=readthrough.id class="is-small" %}
-
+ {% 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" %}
+
{% for progress_update in readthrough.progress_updates %}
{% csrf_token %}
{{ progress_update.created_date | naturalday }}:
{% if progress_update.mode == 'PG' %}
- page {{ progress_update.progress }} of {{ book.pages }}
+ {% include 'snippets/page_text.html' with page=progress_update.progress total_pages=book.pages %}
{% else %}
{{ progress_update.progress }}%
{% endif %}
- Delete this progress update
+ {% trans "Delete this progress update" %}
@@ -35,16 +48,20 @@
{% endif %}
{% endif %}
- {{ readthrough.start_date | naturalday }}: started
+ {% if readthrough.start_date %}
+ {{ readthrough.start_date | localtime | naturalday }}: {% trans "started" %}
+ {% endif %}
- {% include 'snippets/toggle/toggle_button.html' with class="is-small" text="Edit read dates" icon="pencil" controls_text="edit-readthrough" controls_uid=readthrough.id focus="edit-readthrough" %}
+ {% trans "Edit read dates" as button_text %}
+ {% include 'snippets/toggle/toggle_button.html' with class="is-small" text=button_text icon="pencil" controls_text="edit-readthrough" controls_uid=readthrough.id focus="edit-readthrough" %}
- {% include 'snippets/toggle/toggle_button.html' with class="is-small" text="Delete these read dates" icon="x" controls_text="delete-readthrough" controls_uid=readthrough.id focus="modal-title-delete-readthrough" %}
+ {% trans "Delete these read dates" as button_text %}
+ {% include 'snippets/toggle/toggle_button.html' with class="is-small" text=button_text icon="x" controls_text="delete-readthrough" controls_uid=readthrough.id focus="modal-title-delete-readthrough" %}
@@ -52,14 +69,15 @@
-
-
Edit read dates
+
+
{% trans "Edit read dates" %}
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %}
- Save
- {% include 'snippets/toggle/close_button.html' with text="Cancel" controls_text="edit-readthrough" controls_uid=readthrough.id %}
+ {% trans "Save" %}
+ {% trans "Cancel" as button_text %}
+ {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit-readthrough" controls_uid=readthrough.id %}
-{% include 'snippets/delete_readthrough_modal.html' with controls_text="delete-readthrough" controls_uid=readthrough.id %}
+{% include 'snippets/delete_readthrough_modal.html' with controls_text="delete-readthrough" controls_uid=readthrough.id no_body=True %}
diff --git a/bookwyrm/templates/components/dropdown.html b/bookwyrm/templates/components/dropdown.html
index 1e45fe51..35caa55b 100644
--- a/bookwyrm/templates/components/dropdown.html
+++ b/bookwyrm/templates/components/dropdown.html
@@ -1,13 +1,34 @@
-{% load bookwyrm_tags %}
+{% spaceless %}
+{% load utilities %}
+
{% with 0|uuid as uuid %}
-