diff --git a/.coveragerc b/.coveragerc index 35bf78f5f..084064509 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 3bf9f2c5b..a5130c8bd 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 000000000..58ba190d7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,36 @@ +# @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 + +[*.{md,markdown}] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 +max_line_length = off + +[{package.json,yarn.lock}] +indent_size = unset +indent_style = unset +max_line_length = unset diff --git a/.env.example b/.env.example index 7a67045cd..cf3705af0 100644 --- a/.env.example +++ b/.env.example @@ -22,8 +22,14 @@ 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 + +# Celery config with redis broker +CELERY_BROKER=redis://redis_broker:6379/0 +CELERY_RESULT_BACKEND=redis://redis_broker:6379/0 EMAIL_HOST="smtp.mailgun.org" EMAIL_PORT=587 diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 000000000..d39859f19 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,10 @@ +/* global module */ + +module.exports = { + "env": { + "browser": true, + "es6": true + }, + + "extends": "eslint:recommended" +}; diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea782..56af524e4 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 index de770ccee..5fc849d61 100644 --- a/.github/workflows/black.yml +++ b/.github/workflows/black.yml @@ -1,4 +1,4 @@ -name: Lint +name: Lint Python on: [push, pull_request] @@ -8,6 +8,6 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 - - uses: psf/black@stable + - uses: psf/black@20.8b1 with: args: ". --check -l 80 -S" diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index e734d18ec..3ce368ecd 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -65,4 +65,4 @@ jobs: 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 000000000..978bbbbe5 --- /dev/null +++ b/.github/workflows/lint-frontend.yaml @@ -0,0 +1,29 @@ +# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions +name: Lint Frontend + +on: + push: + branches: [ main, ci ] + paths: + - '.github/workflows/**' + - 'static/**' + pull_request: + branches: [ main, ci ] + +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 + + - name: Run stylelint + run: yarn stylelint **/static/**/*.css --report-needless-disables --report-invalid-scope-disables + + - name: Run ESLint + run: yarn eslint . --ext .js,.jsx,.ts,.tsx diff --git a/.github/workflows/lint-global.yaml b/.github/workflows/lint-global.yaml new file mode 100644 index 000000000..818939702 --- /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 4b5b7fef2..71fa61bfa 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ *.pyc *.swp **/__pycache__ +.local # VSCode /.vscode @@ -17,3 +18,9 @@ # Testing .coverage + +#PyCharm +.idea + +#Node tools +/node_modules/ diff --git a/.stylelintignore b/.stylelintignore new file mode 100644 index 000000000..f456cb226 --- /dev/null +++ b/.stylelintignore @@ -0,0 +1,2 @@ +bookwyrm/static/css/bulma.*.css* +bookwyrm/static/css/icons.css diff --git a/.stylelintrc.js b/.stylelintrc.js new file mode 100644 index 000000000..eadc4a893 --- /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 a60597ae8..3ab87444d 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/README.md b/README.md index 414c01645..e798fedf5 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,16 @@ 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) +- [Setting up the developer environment](#setting-up-the-developer-environment) +- [Installing in Production](#installing-in-production) +- [Book data](#book-data) ## 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. -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). - - ## Contributing There are many ways you can contribute to this project, regardless of your level of technical expertise. @@ -47,49 +44,50 @@ Federation makes it possible to have small, self-determining communities, in con ### 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 +- 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](http://activitypub.rocks/) federation +- [Celery](http://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 +- [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 @@ -147,10 +145,10 @@ You can add the `-l ` to only compile one language. When you refr This project is still young and isn't, at the moment, very stable, so please proceed with caution when running in production. ### Server setup - - Get a domain name and set up DNS for your server - - Set your server up with appropriate firewalls for running a web application (this instruction set is tested against Ubuntu 20.04) - - Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings - - Install Docker and docker-compose +- Get a domain name and set up DNS for your server +- Set your server up with appropriate firewalls for running a web application (this instruction set is tested against Ubuntu 20.04) +- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings +- Install Docker and docker-compose ### Install and configure BookWyrm @@ -158,37 +156,55 @@ The `production` branch of BookWyrm contains a number of tools not on the `main` Instructions for running BookWyrm in production: - - Get the application code: - `git clone git@github.com:mouse-reeve/bookwyrm.git` - - Switch to the `production` branch - `git checkout production` - - Create your environment variables file - `cp .env.example .env` - - Add your domain, email address, SMTP credentials - - Set a secure redis password and secret key - - Set a secure database password for postgres - - Update your nginx configuration in `nginx/default.conf` - - Replace `your-domain.com` with your domain name - - If you aren't using the `www` subdomain, remove the www.your-domain.com version of the domain from the `server_name` in the first server block in `nginx/default.conf` and remove the `-d www.${DOMAIN}` flag at the end of the `certbot` command in `docker-compose.yml`. - - If you are running another web-server on your host machine, you will need to follow the [reverse-proxy instructions](#running-bookwyrm-behind-a-reverse-proxy) - - Run the application (this should also set up a Certbot ssl cert for your domain) with - `docker-compose up --build`, and make sure all the images build successfully +- Get the application code: + `git clone git@github.com:mouse-reeve/bookwyrm.git` +- Switch to the `production` branch + `git checkout production` +- Create your environment variables file + `cp .env.example .env` + - Add your domain, email address, SMTP credentials + - Set a secure redis password and secret key + - Set a secure database password for postgres +- Update your nginx configuration in `nginx/default.conf` + - Replace `your-domain.com` with your domain name +- Run the application (this should also set up a Certbot ssl cert for your domain) with + `docker-compose up --build`, and make sure all the images build successfully +- When docker has built successfully, stop the process with `CTRL-C` +- Comment out the `command: certonly...` line in `docker-compose.yml` +- Run docker-compose in the background with: `docker-compose up -d` +- Initialize the database with: `./bw-dev initdb` +- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U ` and saves the backup to a safe location +- Get the application code: + `git clone git@github.com:mouse-reeve/bookwyrm.git` +- Switch to the `production` branch + `git checkout production` +- Create your environment variables file + `cp .env.example .env` + - Add your domain, email address, SMTP credentials + - Set a secure redis password and secret key + - Set a secure database password for postgres +- Update your nginx configuration in `nginx/default.conf` + - Replace `your-domain.com` with your domain name + - If you aren't using the `www` subdomain, remove the www.your-domain.com version of the domain from the `server_name` in the first server block in `nginx/default.conf` and remove the `-d www.${DOMAIN}` flag at the end of the `certbot` command in `docker-compose.yml`. + - If you are running another web-server on your host machine, you will need to follow the [reverse-proxy instructions](#running-bookwyrm-behind-a-reverse-proxy) +- Run the application (this should also set up a Certbot ssl cert for your domain) with + `docker-compose up --build`, and make sure all the images build successfully - If you are running other services on your host machine, you may run into errors where services fail when attempting to bind to a port. See the [troubleshooting guide](#port-conflicts) for advice on resolving this. - - When docker has built successfully, stop the process with `CTRL-C` - - Comment out the `command: certonly...` line in `docker-compose.yml`, and uncomment the following line (`command: renew ...`) so that the certificate will be automatically renewed. - - Uncomment the https redirect and `server` block in `nginx/default.conf` (lines 17-48). - - Run docker-compose in the background with: `docker-compose up -d` - - Initialize the database with: `./bw-dev initdb` +- When docker has built successfully, stop the process with `CTRL-C` +- Comment out the `command: certonly...` line in `docker-compose.yml`, and uncomment the following line (`command: renew ...`) so that the certificate will be automatically renewed. +- Uncomment the https redirect and `server` block in `nginx/default.conf` (lines 17-48). +- Run docker-compose in the background with: `docker-compose up -d` +- Initialize the database with: `./bw-dev initdb` Congrats! You did it, go to your domain and enjoy the fruits of your labors. ### Configure your instance - - Register a user account in the application UI - - Make your account a superuser (warning: do *not* use django's `createsuperuser` command) - - On your server, open the django shell +- Register a user account in the application UI +- Make your account a superuser (warning: do *not* use django's `createsuperuser` command) + - On your server, open the django shell `./bw-dev shell` - - Load your user and make it a superuser + - Load your user and make it a superuser ```python from bookwyrm import models user = models.User.objects.get(id=1) @@ -196,18 +212,7 @@ Congrats! You did it, go to your domain and enjoy the fruits of your labors. user.is_superuser = True user.save() ``` - - Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance - - -## Book data -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. + - Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance ### Backups @@ -216,12 +221,26 @@ Backups are named `backup__%Y-%m-%d.sql`. The db service has an optional script for periodically pruning the backups directory so that all recent daily backups are kept, but for older backups, only weekly or monthly backups are kept. To enable this script: - - Uncomment the final line in `postgres-docker/cronfile` - - rebuild your instance `docker-compose up --build` +- Uncomment the final line in `postgres-docker/cronfile` +- rebuild your instance `docker-compose up --build` You can copy backups from the backups volume to your host machine with `docker cp`: - - Run `docker-compose ps` to confirm the db service's full name (it's probably `bookwyrm_db_1`. - - Run `docker cp :/backups ` +- Run `docker-compose ps` to confirm the db service's full name (it's probably `bookwyrm_db_1`. +- Run `docker cp :/backups ` + +### Updating your instance + +When there are changes available in the production branch, you can install and get them running on your instance using the command `./bw-dev update`. This does a number of things: +- `git pull` gets the updated code from the git repository. If there are conflicts, you may need to run `git pull` separately and resolve the conflicts before trying the `./bw-dev update` script again. +- `docker-compose build` rebuilds the images, which ensures that the correct packages are installed. This step takes a long time and is only needed when the dependencies (including pip `requirements.txt` packages) have changed, so you can comment it out if you want a quicker update path and don't mind un-commenting it as needed. +- `docker-compose exec web python manage.py migrate` runs the database migrations in Django +- `docker-compose exec web python manage.py collectstatic --no-input` loads any updated static files (such as the JavaScript and CSS) +- `docker-compose restart` reloads the docker containers + +### Re-building activity streams + +If something goes awry with user timelines, and you want to re-create them en mass, there's a management command for that: +`docker-compose run --rm web python manage.py rebuild_feeds` ### Port Conflicts @@ -230,15 +249,15 @@ This means that, depending on what else you are running on your host machine, yo If this occurs, you will need to change your configuration to run services on different ports. This may require one or more changes the following files: - - `docker-compose.yml` - - `nginx/default.conf` - - `.env` (You create this file yourself during setup) +- `docker-compose.yml` +- `nginx/default.conf` +- `.env` (You create this file yourself during setup) E.g., If you need Redis to run on a different port: - - In `docker-compose.yml`: +- In `docker-compose.yml`: - In `services` -> `redis` -> `command`, add `--port YOUR_PORT` to the command - In `services` -> `redis` -> `ports`, change `6379:6379` to your port - - In `.env`, update `REDIS_PORT` +- In `.env`, update `REDIS_PORT` If you are already running a web-server on your machine, you will need to set up a reverse-proxy. @@ -250,11 +269,11 @@ The default BookWyrm configuration already has an nginx server that proxies requ The static files are stored in a Docker volume that several BookWyrm services access, so it is not recommended to remove this server completely. To run BookWyrm behind a reverse-proxy, make the following changes: - - In `nginx/default.conf`: +- In `nginx/default.conf`: - Comment out the two default servers - Uncomment the server labeled Reverse-Proxy server - Replace `your-domain.com` with your domain name - - In `docker-compose.yml`: +- In `docker-compose.yml`: - In `services` -> `nginx` -> `ports`, comment out the default ports and add `- 8001:8001` - In `services` -> `nginx` -> `volumes`, comment out the two volumes that begin `./certbot/` - In `services`, comment out the `certbot` service @@ -270,35 +289,46 @@ Before you can set up nginx, you will need to locate your nginx configuration di See [nginx's guide](http://nginx.org/en/docs/beginners_guide.html) for details. To set up your server: - - In you `nginx.conf` file, ensure that `include servers/*;` isn't commented out. - - In your nginx `servers` directory, create a new file named after your domain containing the following information: - ```nginx - server { - server_name your-domain.com www.your-domain.com; +- In you `nginx.conf` file, ensure that `include servers/*;` isn't commented out. +- In your nginx `servers` directory, create a new file named after your domain containing the following information: + ```nginx + server { + server_name your-domain.com www.your-domain.com; - location / { - proxy_pass http://localhost:8000; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } + location / { + proxy_pass http://localhost:8000; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + } - location /images/ { - proxy_pass http://localhost:8001; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } + location /images/ { + proxy_pass http://localhost:8001; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + } - location /static/ { - proxy_pass http://localhost:8001; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } + location /static/ { + proxy_pass http://localhost:8001; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + } - listen [::]:80 ssl; - listen 80 ssl; - } - ``` - - run `sudo certbot run --nginx --email YOUR_EMAIL -d your-domain.com -d www.your-domain.com` - - restart nginx + listen [::]:80 ssl; + listen 80 ssl; + } + ``` +- run `sudo certbot run --nginx --email YOUR_EMAIL -d your-domain.com -d www.your-domain.com` +- restart nginx -If everything worked correctly, your BookWyrm instance should now be externally accessible. \ No newline at end of file +If everything worked correctly, your BookWyrm instance should now be externally accessible. + + +## 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. diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 791502d01..768eb2084 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -248,12 +248,14 @@ def get_model_from_type(activity_type): return model[0] -def resolve_remote_id(remote_id, model=None, refresh=False, save=True): +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 + return result if not get_activity else result.to_activity_dataclass() # load the data and create the object try: @@ -269,8 +271,11 @@ def resolve_remote_id(remote_id, model=None, refresh=False, save=True): # check for existing items with shared unique identifiers result = model.find_existing(data) if result and not refresh: - return result + 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=model, instance=result, save=save) diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index f1298b927..4ab9f08e6 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -21,14 +21,14 @@ class Person(ActivityObject): preferredUsername: str inbox: str - outbox: str - followers: str publicKey: PublicKey + followers: 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 + discoverable: str = False type: str = "Person" diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index d684171e9..3686b3f3e 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -1,5 +1,5 @@ """ undo wrapper activity """ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List from django.apps import apps @@ -191,6 +191,9 @@ class Like(Verb): 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" diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py new file mode 100644 index 000000000..279079c81 --- /dev/null +++ b/bookwyrm/activitystreams.py @@ -0,0 +1,297 @@ +""" access the activity streams stored in redis """ +from abc import ABC +from django.dispatch import receiver +from django.db.models import signals, Q +import redis + +from bookwyrm import models, settings +from bookwyrm.views.helpers import privacy_filter + +r = redis.Redis( + host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0 +) + + +class ActivityStream(ABC): + """ 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_value(self, status): # pylint: disable=no-self-use + """ the status id and the rank (ie, published date) """ + return {status.id: status.published_date.timestamp()} + + def add_status(self, status): + """ add a status to users' feeds """ + value = self.get_value(status) + # we want to do this as a bulk operation, hence "pipeline" + pipeline = r.pipeline() + for user in self.stream_users(status): + # add the status to the feed + pipeline.zadd(self.stream_id(user), value) + pipeline.zremrangebyrank( + self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH + ) + # add to the unread status count + pipeline.incr(self.unread_id(user)) + # and go! + pipeline.execute() + + def remove_status(self, status): + """ remove a status from all feeds """ + pipeline = r.pipeline() + for user in self.stream_users(status): + pipeline.zrem(self.stream_id(user), -1, status.id) + pipeline.execute() + + def add_user_statuses(self, viewer, user): + """ add a user's statuses to another user's feed """ + pipeline = r.pipeline() + statuses = user.status_set.all()[: settings.MAX_STREAM_LENGTH] + for status in statuses: + pipeline.zadd(self.stream_id(viewer), self.get_value(status)) + if statuses: + pipeline.zremrangebyrank( + self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH + ) + pipeline.execute() + + def remove_user_statuses(self, viewer, user): + """ remove a user's status from another user's feed """ + pipeline = r.pipeline() + for status in user.status_set.all()[: settings.MAX_STREAM_LENGTH]: + pipeline.lrem(self.stream_id(viewer), -1, status.id) + pipeline.execute() + + def get_activity_stream(self, user): + """ load the ids for statuses to be displayed """ + # clear unreads for this feed + r.set(self.unread_id(user), 0) + + statuses = r.zrevrange(self.stream_id(user), 0, -1) + return ( + models.Status.objects.select_subclasses() + .filter(id__in=statuses) + .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_stream(self, user): + """ go from zero to a timeline """ + pipeline = r.pipeline() + statuses = self.stream_statuses(user) + + stream_id = self.stream_id(user) + for status in statuses.all()[: settings.MAX_STREAM_LENGTH]: + pipeline.zadd(stream_id, self.get_value(status)) + + # only trim the stream if statuses were added + if statuses.exists(): + pipeline.zremrangebyrank( + self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH + ) + pipeline.execute() + + def stream_users(self, status): # pylint: disable=no-self-use + """ given a status, what users should see it """ + # direct messages don't appeard in feeds, direct comments/reviews/etc do + 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 stream_statuses(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"], + ) + + +class HomeStream(ActivityStream): + """ users you follow """ + + key = "home" + + def stream_users(self, status): + audience = super().stream_users(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 stream_statuses(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 stream_users(self, status): + # this stream wants no part in non-public statuses + if status.privacy != "public" or not status.user.local: + return [] + return super().stream_users(status) + + def stream_statuses(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 stream_users(self, status): + # this stream wants no part in non-public statuses + if status.privacy != "public": + return [] + return super().stream_users(status) + + def stream_statuses(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_status(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_status(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_stream(instance) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 3ed25cebb..caf6bcbe2 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -3,6 +3,9 @@ import importlib 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 @@ -15,6 +18,8 @@ class ConnectorException(HTTPError): def search(query, min_confidence=0.1): """ find books based on arbitary keywords """ + if not query: + return [] results = [] # Have we got a ISBN ? @@ -42,6 +47,7 @@ def search(query, min_confidence=0.1): except (HTTPError, ConnectorException): continue + # if the search results look the same, ignore them 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) @@ -83,7 +89,7 @@ def get_connectors(): 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: @@ -120,3 +126,11 @@ def load_connector(connector_info): "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/emailing.py b/bookwyrm/emailing.py index c7536876d..1804254b0 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -1,27 +1,66 @@ """ send emails """ -from django.core.mail import send_mail +from django.core.mail import EmailMultiAlternatives +from django.template.loader import get_template -from bookwyrm import models +from bookwyrm import models, settings from bookwyrm.tasks import app +from bookwyrm.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, + 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): +def send_email(recipient, subject, html_content, text_content): """ use a task to send the email """ - send_mail( - subject, - message, - None, # sender will be the config default - [recipient], - fail_silently=False, + 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 edf1d9e45..1a114e05f 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -6,7 +6,7 @@ from django import forms from django.forms import ModelForm, PasswordInput, widgets from django.forms.widgets import Textarea from django.utils import timezone -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from bookwyrm import models @@ -76,7 +76,16 @@ class ReviewForm(CustomForm): class CommentForm(CustomForm): class Meta: model = models.Comment - fields = ["user", "book", "content", "content_warning", "sensitive", "privacy"] + fields = [ + "user", + "book", + "content", + "content_warning", + "sensitive", + "privacy", + "progress", + "progress_mode", + ] class QuotationForm(CustomForm): @@ -120,8 +129,23 @@ class EditUserForm(CustomForm): "name", "email", "summary", - "manually_approves_followers", "show_goal", + "manually_approves_followers", + "discoverable", + "preferred_timezone", + ] + help_texts = {f: None for f in fields} + + +class LimitedEditUserForm(CustomForm): + class Meta: + model = models.User + fields = [ + "avatar", + "name", + "summary", + "manually_approves_followers", + "discoverable", ] help_texts = {f: None for f in fields} @@ -193,6 +217,19 @@ class ExpiryWidget(widgets.Select): 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 diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py deleted file mode 100644 index fb4e8e0f1..000000000 --- a/bookwyrm/goodreads_import.py +++ /dev/null @@ -1,14 +0,0 @@ -""" handle reading a csv from goodreads """ -from bookwyrm.importer import Importer - -# GoodReads is the default importer, thus Importer follows its structure. For a more complete example of overriding see librarything_import.py - - -class GoodreadsImporter(Importer): - service = "GoodReads" - - def parse_fields(self, data): - data.update({"import_source": self.service}) - # add missing 'Date Started' field - data.update({"Date Started": None}) - return data diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py new file mode 100644 index 000000000..f13672e06 --- /dev/null +++ b/bookwyrm/importers/__init__.py @@ -0,0 +1,5 @@ +""" import classes """ + +from .importer import Importer +from .goodreads_import import GoodreadsImporter +from .librarything_import import LibrarythingImporter diff --git a/bookwyrm/importers/goodreads_import.py b/bookwyrm/importers/goodreads_import.py new file mode 100644 index 000000000..0b126c14c --- /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/importer.py b/bookwyrm/importers/importer.py similarity index 94% rename from bookwyrm/importer.py rename to bookwyrm/importers/importer.py index 2fbb3430f..ddbfa3048 100644 --- a/bookwyrm/importer.py +++ b/bookwyrm/importers/importer.py @@ -10,6 +10,8 @@ logger = logging.getLogger(__name__) class Importer: + """ Generic class for csv data import from an outside service """ + service = "Unknown" delimiter = "," encoding = "UTF-8" @@ -29,10 +31,12 @@ class Importer: self.save_item(job, index, entry) return job - def save_item(self, job, index, data): + 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 diff --git a/bookwyrm/librarything_import.py b/bookwyrm/importers/librarything_import.py similarity index 50% rename from bookwyrm/librarything_import.py rename to bookwyrm/importers/librarything_import.py index b3dd9d56b..3755cb1ad 100644 --- a/bookwyrm/librarything_import.py +++ b/bookwyrm/importers/librarything_import.py @@ -1,35 +1,35 @@ """ handle reading a csv from librarything """ -import csv import re import math -from bookwyrm import models -from bookwyrm.models import ImportItem -from bookwyrm.importer import Importer +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, initial): + def parse_fields(self, entry): + """ custom parsing for librarything """ data = {} data["import_source"] = self.service - data["Book Id"] = initial["Book Id"] - data["Title"] = initial["Title"] - data["Author"] = initial["Primary Author"] - data["ISBN13"] = initial["ISBN"] - data["My Review"] = initial["Review"] - if initial["Rating"]: - data["My Rating"] = math.ceil(float(initial["Rating"])) + 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("\[|\]", "", initial["Entry Date"]) - data["Date Started"] = re.sub("\[|\]", "", initial["Date Started"]) - data["Date Read"] = re.sub("\[|\]", "", initial["Date Read"]) + 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"]: diff --git a/bookwyrm/management/commands/erase_streams.py b/bookwyrm/management/commands/erase_streams.py new file mode 100644 index 000000000..042e857fc --- /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/populate_streams.py b/bookwyrm/management/commands/populate_streams.py new file mode 100644 index 000000000..06ca5f075 --- /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_stream(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/migrations/0055_auto_20210321_0101.py b/bookwyrm/migrations/0055_auto_20210321_0101.py new file mode 100644 index 000000000..dea219c4d --- /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 000000000..aa475e033 --- /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 000000000..c49592bf3 --- /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 000000000..0fa8c90b6 --- /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 000000000..122ec1216 --- /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 000000000..acd009779 --- /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 000000000..a6899aa35 --- /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/models/__init__.py b/bookwyrm/models/__init__.py index 326a673e1..35e32c2cf 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -26,7 +26,7 @@ from .federated_server import FederatedServer from .import_job import ImportJob, ImportItem -from .site import SiteSettings, SiteInvite, PasswordReset +from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) activity_models = { diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 60e5da0ad..cb2fc851e 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -34,7 +34,7 @@ class BookWyrmModel(models.Model): @receiver(models.signals.post_save) # pylint: disable=unused-argument -def execute_after_save(sender, instance, created, *args, **kwargs): +def set_remote_id(sender, instance, created, *args, **kwargs): """ set the remote_id after save (when the id is available) """ if not created or not hasattr(instance, "get_remote_id"): return diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index 247c6aca6..e034d59ee 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -448,3 +448,8 @@ class IntegerField(ActivitypubFieldMixin, models.IntegerField): 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/list.py b/bookwyrm/models/list.py index a05325f3f..880c41229 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -1,6 +1,7 @@ """ 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 @@ -79,6 +80,10 @@ class ListItem(CollectionItemMixin, BookWyrmModel): """ 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: diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index 3445573c4..1a5fcb0d5 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -7,6 +7,8 @@ from .base_model import BookWyrmModel class ProgressMode(models.TextChoices): + """ types of prgress available """ + PAGE = "PG", "page" PERCENT = "PCT", "percent" @@ -32,10 +34,12 @@ class ReadThrough(BookWyrmModel): 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 ) + return None class ProgressUpdate(BookWyrmModel): diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index df99d2165..998d7bed5 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -62,7 +62,7 @@ class UserFollows(ActivityMixin, UserRelationship): status = "follows" - def to_activity(self): + def to_activity(self): # pylint: disable=arguments-differ """ overrides default to manually set serializer """ return activitypub.Follow(**generate_activity(self)) diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 3185b47f7..3209da5d9 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -11,6 +11,12 @@ from . import fields class Shelf(OrderedCollectionMixin, BookWyrmModel): """ 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( @@ -31,9 +37,13 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): """ 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): @@ -43,7 +53,8 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): def get_remote_id(self): """ 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 """ diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 7fde6781e..1eb318694 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -3,10 +3,11 @@ 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 @@ -24,6 +25,7 @@ class SiteSettings(models.Model): code_of_conduct = models.TextField(default="Add a code of conduct here.") privacy_policy = models.TextField(default="Add a privacy policy here.") allow_registration = models.BooleanField(default=True) + allow_invite_requests = 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) @@ -56,6 +58,7 @@ class SiteInvite(models.Model): 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 """ @@ -69,6 +72,23 @@ class SiteInvite(models.Model): 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 """ now = timezone.now() diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 09a7c4ec1..360288e93 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -14,6 +14,7 @@ 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 @@ -57,6 +58,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): 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 """ super().save(*args, **kwargs) @@ -65,6 +71,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): if self.deleted: notification_model.objects.filter(related_status=self).delete() + return if ( self.reply_parent @@ -113,16 +120,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): return list(set(mentions)) @classmethod - def ignore_activity(cls, activity): + 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, save=False) + 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.to_activity_dataclass()) + return cls.ignore_activity(boosted) # keep if it if it's a custom type if activity.type != "Note": @@ -229,6 +238,19 @@ class Comment(Status): "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 """ @@ -285,13 +307,10 @@ class Review(Status): @property def pure_name(self): """ clarify review names for mastodon serialization """ - if self.rating: - return 'Review of "{}" ({:d} stars): {}'.format( - self.book.title, - self.rating, - self.name, - ) - return 'Review of "{}": {}'.format(self.book.title, self.name) + 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): @@ -333,7 +352,7 @@ class Boost(ActivityMixin, Status): def save(self, *args, **kwargs): """ save and notify """ super().save(*args, **kwargs) - if not self.boosted_status.user.local: + 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) @@ -359,7 +378,7 @@ class Boost(ActivityMixin, Status): """ the user field is "actor" here instead of "attributedTo" """ super().__init__(*args, **kwargs) - reserve_fields = ["user", "boosted_status"] + 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 = [] diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 46f08509f..33dedc9ef 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import AbstractUser, Group from django.core.validators import MinValueValidator from django.db import models from django.utils import timezone +import pytz from bookwyrm import activitypub from bookwyrm.connectors import get_data, ConnectorException @@ -49,7 +50,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): 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) @@ -103,6 +104,12 @@ class User(OrderedCollectionPageMixin, AbstractUser): 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, + ) name_field = "username" @@ -175,10 +182,10 @@ class User(OrderedCollectionPageMixin, AbstractUser): **kwargs ) - def to_activity(self): + 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() + activity_object = super().to_activity(**kwargs) activity_object["@context"] = [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1", @@ -286,10 +293,10 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): self.private_key, self.public_key = create_key_pair() return super().save(*args, **kwargs) - def to_activity(self): + 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() + activity_object = super().to_activity(**kwargs) del activity_object["@context"] del activity_object["type"] return activity_object @@ -353,7 +360,7 @@ def set_remote_server(user_id): actor_parts = urlparse(user.remote_id) user.federated_server = get_or_create_remote_server(actor_parts.netloc) user.save(broadcast=False) - if user.bookwyrm_user: + if user.bookwyrm_user and user.outbox: get_remote_reviews.delay(user.outbox) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index bcff58287..845f81c46 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -25,6 +25,7 @@ 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) +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__))) @@ -66,6 +67,7 @@ MIDDLEWARE = [ "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", ] @@ -92,6 +94,12 @@ TEMPLATES = [ 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) + +MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200)) +STREAMS = ["home", "local", "federated"] # Database # https://docs.djangoproject.com/en/2.0/ref/settings/#databases diff --git a/bookwyrm/static/css/fonts/.editorconfig b/bookwyrm/static/css/fonts/.editorconfig new file mode 100644 index 000000000..2e5ec87a5 --- /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 index 435d8eb9e..a01aff827 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -7,6 +7,7 @@ html { .image { overflow: hidden; } + .navbar .logo { max-height: 50px; } @@ -14,23 +15,45 @@ html { .card { overflow: visible; } -.card-header-title { + +.scroll-x { overflow: hidden; + overflow-x: auto; +} + +.modal-card.is-fullwidth { + min-width: 75% !important; +} + +/* --- "disabled" for non-buttons --- */ +.is-disabled { + background-color: #dbdbdb; + border-color: #dbdbdb; + box-shadow: none; + color: #7a7a7a; + opacity: 0.5; + cursor: not-allowed; } /* --- SHELVING --- */ + +/** @todo Replace icons with SVG symbols. + @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ .shelf-option:disabled > *::after { - font-family: "icomoon"; + 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 { +.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] { + +.hide-active[aria-pressed=true], +.hide-inactive[aria-pressed=false] { display: none; } @@ -38,44 +61,65 @@ html { display: none !important; } -/* --- STARS --- */ -.rate-stars button.icon { - background: none; - border: none; - padding: 0; +.hidden.transition-y, +.hidden.transition-x { + display: block !important; + visibility: hidden !important; + height: 0; + width: 0; margin: 0; - display: inline; + padding: 0; } -.rate-stars:hover .icon:before { + +.transition-y { + transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom; + transition-duration: 0.5s; + transition-timing-function: ease; +} + +.transition-x { + transition-property: width, margin-left, margin-right, padding-left, padding-right; + transition-duration: 0.5s; + transition-timing-function: ease; +} + +@media (prefers-reduced-motion: reduce) { + .transition-x, + .transition-y { + transition-duration: 0.001ms !important; + } +} + +/** 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'; } -.rate-stars form:hover ~ form .icon:before{ + +/* Icons directly following inputs that follow the checked input are emptied. */ +.form-rate-stars input:checked ~ input + .icon::before { content: '\e9d7'; } -/* stars in a review form */ -.form-rate-stars:hover .icon:before { +/* 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 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'; -} +.form-rate-stars .icon:hover ~ .icon::before { + content: '\e9d7'; +} /* --- BOOK COVERS --- */ .cover-container { @@ -83,46 +127,46 @@ html { 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-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; @@ -132,32 +176,51 @@ html { text-align: center; } +.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; +} /* --- AVATAR --- */ .avatar { vertical-align: middle; display: inline; } -.navbar .avatar { - max-height: none; + +.is-32x32 { + min-width: 32px; + min-height: 32px; } +.is-96x96 { + min-width: 96px; + min-height: 96px; +} /* --- QUOTES --- */ .quote blockquote { position: relative; padding-left: 2em; } -.quote blockquote:before, .quote blockquote:after { + +.quote blockquote::before, +.quote blockquote::after { font-family: 'icomoon'; position: absolute; } -.quote blockquote:before { + +.quote blockquote::before { content: "\e906"; top: 0; left: 0; } -.quote blockquote:after { + +.quote blockquote::after { content: "\e905"; right: 0; } diff --git a/bookwyrm/static/css/icons.css b/bookwyrm/static/css/icons.css index c84446afa..9915ecd18 100644 --- a/bookwyrm/static/css/icons.css +++ b/bookwyrm/static/css/icons.css @@ -1,153 +1,153 @@ @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; + 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; + /* 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; + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } .icon-graphic-heart:before { - content: "\e91e"; + content: "\e91e"; } .icon-graphic-paperplane:before { - content: "\e91f"; + content: "\e91f"; } .icon-graphic-banknote:before { - content: "\e920"; + content: "\e920"; } .icon-stars:before { - content: "\e91a"; + content: "\e91a"; } .icon-warning:before { - content: "\e91b"; + content: "\e91b"; } .icon-book:before { - content: "\e900"; + content: "\e900"; } .icon-bookmark:before { - content: "\e91c"; + content: "\e91c"; } .icon-rss:before { - content: "\e91d"; + content: "\e91d"; } .icon-envelope:before { - content: "\e901"; + content: "\e901"; } .icon-arrow-right:before { - content: "\e902"; + content: "\e902"; } .icon-bell:before { - content: "\e903"; + content: "\e903"; } .icon-x:before { - content: "\e904"; + content: "\e904"; } .icon-quote-close:before { - content: "\e905"; + content: "\e905"; } .icon-quote-open:before { - content: "\e906"; + content: "\e906"; } .icon-image:before { - content: "\e907"; + content: "\e907"; } .icon-pencil:before { - content: "\e908"; + content: "\e908"; } .icon-list:before { - content: "\e909"; + content: "\e909"; } .icon-unlock:before { - content: "\e90a"; + content: "\e90a"; } .icon-unlisted:before { - content: "\e90a"; + content: "\e90a"; } .icon-globe:before { - content: "\e90b"; + content: "\e90b"; } .icon-public:before { - content: "\e90b"; + content: "\e90b"; } .icon-lock:before { - content: "\e90c"; + content: "\e90c"; } .icon-followers:before { - content: "\e90c"; + content: "\e90c"; } .icon-chain-broken:before { - content: "\e90d"; + content: "\e90d"; } .icon-chain:before { - content: "\e90e"; + content: "\e90e"; } .icon-comments:before { - content: "\e90f"; + content: "\e90f"; } .icon-comment:before { - content: "\e910"; + content: "\e910"; } .icon-boost:before { - content: "\e911"; + content: "\e911"; } .icon-arrow-left:before { - content: "\e912"; + content: "\e912"; } .icon-arrow-up:before { - content: "\e913"; + content: "\e913"; } .icon-arrow-down:before { - content: "\e914"; + content: "\e914"; } .icon-home:before { - content: "\e915"; + content: "\e915"; } .icon-local:before { - content: "\e916"; + content: "\e916"; } .icon-dots-three:before { - content: "\e917"; + content: "\e917"; } .icon-check:before { - content: "\e918"; + content: "\e918"; } .icon-dots-three-vertical:before { - content: "\e919"; + content: "\e919"; } .icon-search:before { - content: "\e986"; + content: "\e986"; } .icon-star-empty:before { - content: "\e9d7"; + content: "\e9d7"; } .icon-star-half:before { - content: "\e9d8"; + content: "\e9d8"; } .icon-star-full:before { - content: "\e9d9"; + content: "\e9d9"; } .icon-heart:before { - content: "\e9da"; + content: "\e9da"; } .icon-plus:before { - content: "\ea0a"; + content: "\ea0a"; } diff --git a/bookwyrm/static/images/logo-small.png b/bookwyrm/static/images/logo-small.png index 10ea7a38f..72f49ef78 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/check_all.js b/bookwyrm/static/js/check_all.js index ea2300ced..07d30a686 100644 --- a/bookwyrm/static/js/check_all.js +++ b/bookwyrm/static/js/check_all.js @@ -1,4 +1,4 @@ -// Toggle all checkboxes. +/* exported toggleAllCheckboxes */ /** * Toggle all descendant checkboxes of a target. diff --git a/bookwyrm/static/js/localstorage.js b/bookwyrm/static/js/localstorage.js index b63c43928..aa79ee303 100644 --- a/bookwyrm/static/js/localstorage.js +++ b/bookwyrm/static/js/localstorage.js @@ -1,3 +1,6 @@ +/* exported updateDisplay */ +/* globals addRemoveClass */ + // set javascript listeners function updateDisplay(e) { // used in set reading goal diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js index d390f482f..7a198619c 100644 --- a/bookwyrm/static/js/shared.js +++ b/bookwyrm/static/js/shared.js @@ -1,3 +1,5 @@ +/* globals setDisplay TabGroup toggleAllCheckboxes updateDisplay */ + // set up javascript listeners window.onload = function() { // buttons that display or hide content @@ -61,9 +63,9 @@ function polling(el, delay) { function updateCountElement(el, data) { const currentCount = el.innerText; - const count = data[el.getAttribute('data-poll')]; + const count = data.count; if (count != currentCount) { - addRemoveClass(el, 'hidden', count < 1); + addRemoveClass(el.closest('[data-poll-wrapper]'), 'hidden', count < 1); el.innerText = count; } } @@ -93,7 +95,7 @@ function toggleAction(e) { // show/hide container var container = document.getElementById('hide-' + targetId); - if (!!container) { + if (container) { addRemoveClass(container, 'hidden', pressed); } diff --git a/bookwyrm/static/js/tabs.js b/bookwyrm/static/js/tabs.js index 1cb525ce9..f9568b29f 100644 --- a/bookwyrm/static/js/tabs.js +++ b/bookwyrm/static/js/tabs.js @@ -1,3 +1,5 @@ +/* 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 @@ -59,7 +61,9 @@ class TabGroup { } initPanels() { - let selectedPanelId = this.tablist.querySelector('[role="tab"][aria-selected="true"]').getAttribute("aria-controls"); + 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", ""); diff --git a/bookwyrm/status.py b/bookwyrm/status.py index 7f0757410..793bd742d 100644 --- a/bookwyrm/status.py +++ b/bookwyrm/status.py @@ -1,18 +1,10 @@ """ 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 """ # sanitize input html diff --git a/bookwyrm/templates/notfound.html b/bookwyrm/templates/404.html similarity index 100% rename from bookwyrm/templates/notfound.html rename to bookwyrm/templates/404.html diff --git a/bookwyrm/templates/error.html b/bookwyrm/templates/500.html similarity index 100% rename from bookwyrm/templates/error.html rename to bookwyrm/templates/500.html diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 0d908110b..b91cebbac 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -35,20 +35,27 @@
-
- {% 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' %} +
+
+ {% 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 %}
{% 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 %} -
+
{% if book.isbn_13 %}
@@ -72,24 +79,7 @@ {% endif %}
-

- {% if book.physical_format and not book.pages %} - {{ book.physical_format | title }} - {% elif book.physical_format and book.pages %} - {% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %} - {% elif book.pages %} - {% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %} - {% endif %} -

-

- {% if book.published_date and book.publishers %} - {% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} - {% elif book.published_date %} - {% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %} - {% elif book.publishers %} - {% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %} - {% endif %} -

+ {% include 'book/publisher_info.html' with book=book %} {% if book.openlibrary_key %}

{% trans "View on OpenLibrary" %}

@@ -178,32 +168,11 @@ {% 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 %}
- -
-
- - {% csrf_token %} - - - -
-
{% endif %} - -
-
- {% for tag in tags %} - {% include 'snippets/tag.html' with book=book tag=tag user_tags=user_tags %} - {% endfor %} -
-
-
{% if book.subjects %} @@ -296,3 +265,7 @@
{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit_book.html index d7c842351..a9ce651e7 100644 --- a/bookwyrm/templates/book/edit_book.html +++ b/bookwyrm/templates/book/edit_book.html @@ -122,12 +122,18 @@

{{ error | escape }}

{% endfor %} -

{{ form.first_published_date }}

+

+ + +

{% for error in form.first_published_date.errors %}

{{ error | escape }}

{% endfor %} -

{{ form.published_date }}

+

+ + +

{% for error in form.published_date.errors %}

{{ error | escape }}

{% endfor %} diff --git a/bookwyrm/templates/book/edition_filters.html b/bookwyrm/templates/book/edition_filters.html new file mode 100644 index 000000000..a55b72af0 --- /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 000000000..91259465e --- /dev/null +++ b/bookwyrm/templates/book/editions.html @@ -0,0 +1,40 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load bookwyrm_tags %} + +{% block title %}{% blocktrans with book_title=work.title %}Editions of {{ book_title }}{% endblocktrans %}{% endblock %} + +{% block content %} +
+

{% blocktrans with work_path=work.local_path work_title=work.title %}Editions of "{{ work_title }}"{% endblocktrans %}

+
+ +{% include 'book/edition_filters.html' %} + +
+ {% for book in editions %} +
+ +
+

+ + {{ book.title }} + +

+ {% include 'book/publisher_info.html' with book=book %} +
+
+ {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=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 000000000..c722b24f8 --- /dev/null +++ b/bookwyrm/templates/book/format_filter.html @@ -0,0 +1,16 @@ +{% extends 'snippets/filters_panel/filter_field.html' %} +{% load i18n %} + +{% block filter %} + +
+ +
+{% endblock %} diff --git a/bookwyrm/templates/book/language_filter.html b/bookwyrm/templates/book/language_filter.html new file mode 100644 index 000000000..d9051fd81 --- /dev/null +++ b/bookwyrm/templates/book/language_filter.html @@ -0,0 +1,16 @@ +{% extends 'snippets/filters_panel/filter_field.html' %} +{% load i18n %} + +{% block filter %} + +
+ +
+{% endblock %} diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html new file mode 100644 index 000000000..0ab354012 --- /dev/null +++ b/bookwyrm/templates/book/publisher_info.html @@ -0,0 +1,24 @@ +{% load i18n %} +

+ {% if book.physical_format and not book.pages %} + {{ book.physical_format | title }} + {% elif book.physical_format and book.pages %} + {% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %} + {% elif book.pages %} + {% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %} + {% endif %} +

+{% if book.languages %} +

+ {% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %} +

+{% endif %} +

+ {% if book.published_date and book.publishers %} + {% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} + {% elif book.published_date %} + {% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %} + {% elif book.publishers %} + {% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %} + {% endif %} +

diff --git a/bookwyrm/templates/components/modal.html b/bookwyrm/templates/components/modal.html index 9ffbc2b35..3c3669544 100644 --- a/bookwyrm/templates/components/modal.html +++ b/bookwyrm/templates/components/modal.html @@ -1,3 +1,4 @@ +{% load i18n %} diff --git a/bookwyrm/templates/compose.html b/bookwyrm/templates/compose.html new file mode 100644 index 000000000..9b0549ed7 --- /dev/null +++ b/bookwyrm/templates/compose.html @@ -0,0 +1,34 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load bookwyrm_tags %} + +{% block title %}{% trans "Compose status" %}{% endblock %} +{% block content %} +
+

{% trans "Compose status" %}

+
+ +{% with 0|uuid as uuid %} +
+ {% if book %} +
+ +

{% include 'snippets/book_titleby.html' with book=book %}

+
+ {% endif %} +
+ {% if draft.reply_parent %} + {% include 'snippets/status/status.html' with status=draft.reply_parent no_interact=True %} + {% endif %} + + {% if not draft %} + {% include 'snippets/create_status.html' %} + {% else %} + {% include 'snippets/create_status_form.html' %} + {% endif %} +
+
+{% endwith %} +{% endblock %} diff --git a/bookwyrm/templates/directory/community_filter.html b/bookwyrm/templates/directory/community_filter.html new file mode 100644 index 000000000..bd0ba7785 --- /dev/null +++ b/bookwyrm/templates/directory/community_filter.html @@ -0,0 +1,14 @@ +{% extends 'snippets/filters_panel/filter_field.html' %} +{% load i18n %} + +{% block filter %} +{% trans "Community" %} + + +{% endblock %} diff --git a/bookwyrm/templates/directory/directory.html b/bookwyrm/templates/directory/directory.html new file mode 100644 index 000000000..430e85394 --- /dev/null +++ b/bookwyrm/templates/directory/directory.html @@ -0,0 +1,109 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load bookwyrm_tags %} +{% load humanize %} + +{% block title %}{% trans "Directory" %}{% endblock %} + +{% block content %} +
+

+ {% trans "Directory" %} +

+
+ +{% if not request.user.discoverable %} +
+
+

+ {% trans "Make your profile discoverable to other BookWyrm users." %} +

+
+ {% csrf_token %} + +

+ {% url 'settings-profile' as path %} + {% blocktrans %}You can opt-out at any time in your profile settings.{% endblocktrans %} +

+
+
+
+ {% trans "Dismiss message" as button_text %} + +
+
+{% endif %} + +{% include 'directory/filters.html' %} + +
+ {% for user in users %} +
+
+
+ + +
+ {% if user.summary %} + {{ user.summary | to_markdown | safe | truncatechars_html:40 }} + {% else %} {% endif %} +
+
+
+ {% if user != request.user %} + {% if user.mutuals %} + + {% elif user.shared_books %} + + {% endif %} + {% endif %} + + +
+
+
+ {% endfor %} +
+ +
+ {% include 'snippets/pagination.html' with page=users path="/directory" %} +
+ +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/bookwyrm/templates/directory/filters.html b/bookwyrm/templates/directory/filters.html new file mode 100644 index 000000000..c6bbe1575 --- /dev/null +++ b/bookwyrm/templates/directory/filters.html @@ -0,0 +1,7 @@ +{% extends 'snippets/filters_panel/filters_panel.html' %} + +{% block filter_fields %} +{% include 'directory/user_type_filter.html' %} +{% include 'directory/community_filter.html' %} +{% include 'directory/sort_filter.html' %} +{% endblock %} diff --git a/bookwyrm/templates/directory/sort_filter.html b/bookwyrm/templates/directory/sort_filter.html new file mode 100644 index 000000000..82b561fb7 --- /dev/null +++ b/bookwyrm/templates/directory/sort_filter.html @@ -0,0 +1,12 @@ +{% extends 'snippets/filters_panel/filter_field.html' %} +{% load i18n %} + +{% block filter %} + +
+ +
+{% endblock %} diff --git a/bookwyrm/templates/directory/user_type_filter.html b/bookwyrm/templates/directory/user_type_filter.html new file mode 100644 index 000000000..b5961c822 --- /dev/null +++ b/bookwyrm/templates/directory/user_type_filter.html @@ -0,0 +1,14 @@ +{% extends 'snippets/filters_panel/filter_field.html' %} +{% load i18n %} + +{% block filter %} +{% trans "User type" %} + + +{% endblock %} diff --git a/bookwyrm/templates/discover/landing_layout.html b/bookwyrm/templates/discover/landing_layout.html index 5cfa1fd39..8e507531e 100644 --- a/bookwyrm/templates/discover/landing_layout.html +++ b/bookwyrm/templates/discover/landing_layout.html @@ -45,9 +45,33 @@
{% include 'snippets/register_form.html' %}
+ {% else %} +

{% trans "This instance is closed" %}

{{ site.registration_closed_text | safe}}

+ + {% if site.allow_invite_requests %} + {% if request_received %} +

+ {% trans "Thank you! Your request has been received." %} +

+ {% else %} +

{% trans "Request an Invitation" %}

+
+ {% csrf_token %} +
+ + + {% for error in request_form.email.errors %} +

{{ error | escape }}

+ {% endfor %} +
+ +
+ {% endif %} + {% endif %} + {% endif %}
{% else %} diff --git a/bookwyrm/templates/edit_author.html b/bookwyrm/templates/edit_author.html index 0316a66fe..542b57f95 100644 --- a/bookwyrm/templates/edit_author.html +++ b/bookwyrm/templates/edit_author.html @@ -2,11 +2,11 @@ {% load i18n %} {% load humanize %} -{% block title %}{% trans "Edit Author" %}: {{ author.name }}{% endblock %} +{% block title %}{% trans "Edit Author:" %} {{ author.name }}{% endblock %} {% block content %}
-

+

Edit "{{ author.name }}"

diff --git a/bookwyrm/templates/editions.html b/bookwyrm/templates/editions.html deleted file mode 100644 index f83197579..000000000 --- a/bookwyrm/templates/editions.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'layout.html' %} -{% load i18n %} -{% load bookwyrm_tags %} - -{% block title %}{% blocktrans with book_title=work.title %}Editions of {{ book_title }}{% endblocktrans %}{% endblock %} - -{% block content %} -
-

{% blocktrans with work_path=work.local_path work_title=work.title %}Editions of "{{ work_title }}"{% endblocktrans %}

- - {% include 'snippets/book_tiles.html' with books=editions %} -
-{% endblock %} - diff --git a/bookwyrm/templates/email/html_layout.html b/bookwyrm/templates/email/html_layout.html new file mode 100644 index 000000000..02527ff52 --- /dev/null +++ b/bookwyrm/templates/email/html_layout.html @@ -0,0 +1,26 @@ +{% load i18n %} +
+ + +
+

+ {% if user %}{{ user }},{% else %}{% trans "Hi there," %}{% endif %} +

+ {% block content %}{% endblock %} +
+ +
+

{% blocktrans %}BookWyrm hosted on {{ site_name }}{% endblocktrans %}

+ {% if user %} +

{% trans "Email preference" %}

+ {% endif %} +
+
diff --git a/bookwyrm/templates/email/invite/html_content.html b/bookwyrm/templates/email/invite/html_content.html new file mode 100644 index 000000000..358e23dc1 --- /dev/null +++ b/bookwyrm/templates/email/invite/html_content.html @@ -0,0 +1,17 @@ +{% extends 'email/html_layout.html' %} +{% load i18n %} + +{% block content %} +

+ {% blocktrans %}You're invited to join {{ site_name }}!{% endblocktrans %} +

+ +{% trans "Join Now" as text %} +{% include 'email/snippets/action.html' with path=invite_link text=text %} + +

+ {% url 'code-of-conduct' as coc_path %} + {% url 'about' as about_path %} + {% blocktrans %}Learn more about this instance.{% endblocktrans %} +

+{% endblock %} diff --git a/bookwyrm/templates/email/invite/subject.html b/bookwyrm/templates/email/invite/subject.html new file mode 100644 index 000000000..efb8be5ae --- /dev/null +++ b/bookwyrm/templates/email/invite/subject.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}You're invited to join {{ site_name }}!{% endblocktrans %} diff --git a/bookwyrm/templates/email/invite/text_content.html b/bookwyrm/templates/email/invite/text_content.html new file mode 100644 index 000000000..c3fcdc04e --- /dev/null +++ b/bookwyrm/templates/email/invite/text_content.html @@ -0,0 +1,10 @@ +{% extends 'email/text_layout.html' %} +{% load i18n %} +{% block content %} +{% blocktrans %}You're invited to join {{ site_name }}! Click the link below to create an account.{% endblocktrans %} + +{{ invite_link }} + +{% trans "Learn more about this instance:" %} https://{{ domain }}{% url 'about' %} + +{% endblock %} diff --git a/bookwyrm/templates/email/password_reset/html_content.html b/bookwyrm/templates/email/password_reset/html_content.html new file mode 100644 index 000000000..eef0e5e59 --- /dev/null +++ b/bookwyrm/templates/email/password_reset/html_content.html @@ -0,0 +1,15 @@ +{% extends 'email/html_layout.html' %} +{% load i18n %} + +{% block content %} +

+ {% blocktrans %}You requested to reset your {{ site_name }} password. Click the link below to set a new password and log in to your account.{% endblocktrans %} +

+ +{% trans "Reset Password" as text %} +{% include 'email/snippets/action.html' with path=reset_link text=text %} + +

+ {% trans "If you didn't request to reset your password, you can ignore this email." %} +

+{% endblock %} diff --git a/bookwyrm/templates/email/password_reset/subject.html b/bookwyrm/templates/email/password_reset/subject.html new file mode 100644 index 000000000..886801402 --- /dev/null +++ b/bookwyrm/templates/email/password_reset/subject.html @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}Reset your {{ site_name }} password{% endblocktrans %} diff --git a/bookwyrm/templates/email/password_reset/text_content.html b/bookwyrm/templates/email/password_reset/text_content.html new file mode 100644 index 000000000..b5cf754ef --- /dev/null +++ b/bookwyrm/templates/email/password_reset/text_content.html @@ -0,0 +1,9 @@ +{% extends 'email/text_layout.html' %} +{% load i18n %} +{% block content %} +{% blocktrans %}You requested to reset your {{ site_name }} password. Click the link below to set a new password and log in to your account.{% endblocktrans %} + +{{ reset_link }} + +{% trans "If you didn't request to reset your password, you can ignore this email." %} +{% endblock %} diff --git a/bookwyrm/templates/email/preview.html b/bookwyrm/templates/email/preview.html new file mode 100644 index 000000000..66d856c08 --- /dev/null +++ b/bookwyrm/templates/email/preview.html @@ -0,0 +1,19 @@ + + +
+ Subject: {% include subject_path %} +
+
+ + Html email: +
+ {% include html_content_path %} +
+
+ + Text email: +
+ {% include text_content_path %} +
+ + diff --git a/bookwyrm/templates/email/snippets/action.html b/bookwyrm/templates/email/snippets/action.html new file mode 100644 index 000000000..56feb9efd --- /dev/null +++ b/bookwyrm/templates/email/snippets/action.html @@ -0,0 +1,5 @@ +

+ + {{ text }} + +

diff --git a/bookwyrm/templates/email/text_layout.html b/bookwyrm/templates/email/text_layout.html new file mode 100644 index 000000000..cd0444f16 --- /dev/null +++ b/bookwyrm/templates/email/text_layout.html @@ -0,0 +1,3 @@ +{% load i18n %} +{% if user %}{{ user.display_name }},{% else %}{% trans "Hi there," %}{% endif %} +{% block content %}{% endblock %} diff --git a/bookwyrm/templates/feed/direct_messages.html b/bookwyrm/templates/feed/direct_messages.html index 2c28a79b7..f097fd9f4 100644 --- a/bookwyrm/templates/feed/direct_messages.html +++ b/bookwyrm/templates/feed/direct_messages.html @@ -27,7 +27,6 @@
{% endfor %} - {% include 'snippets/pagination.html' with page=activities path="direct-messages" %} {% endblock %} diff --git a/bookwyrm/templates/feed/feed.html b/bookwyrm/templates/feed/feed.html index b7ff6e253..e49b4c138 100644 --- a/bookwyrm/templates/feed/feed.html +++ b/bookwyrm/templates/feed/feed.html @@ -1,9 +1,18 @@ {% extends 'feed/feed_layout.html' %} {% load i18n %} {% load bookwyrm_tags %} +{% load humanize %} {% block panel %} -

{% blocktrans %}{{ tab_title }} Timeline{% endblocktrans %}

+

+ {% if tab == 'home' %} + {% trans "Home Timeline" %} + {% elif tab == 'local' %} + {% trans "Local Timeline" %} + {% else %} + {% trans "Federated Timeline" %} + {% endif %} +

  • @@ -19,6 +28,11 @@
{# announcements and system messages #} +{% if not activities.number > 1 %} + + {% if request.user.show_goal and not goal and tab == 'home' %} {% now 'Y' as year %}
@@ -27,13 +41,25 @@
{% endif %} +{% endif %} + {# activity feed #} {% if not activities %}

{% trans "There aren't any activities right now! Try following a user to get started" %}

{% endif %} + {% for activity in activities %} + +{% if not activities.number > 1 and forloop.counter0 == 2 and suggested_users %} +{# suggested users on the first page, two statuses down #} +
+

{% trans "Who to follow" %}

+ {% include 'feed/suggested_users.html' with suggested_users=suggested_users %} + View directory +
+{% endif %}
-{% include 'snippets/status/status.html' with status=activity %} + {% include 'snippets/status/status.html' with status=activity %}
{% endfor %} diff --git a/bookwyrm/templates/feed/feed_layout.html b/bookwyrm/templates/feed/feed_layout.html index 24294e5d0..9afd5e600 100644 --- a/bookwyrm/templates/feed/feed_layout.html +++ b/bookwyrm/templates/feed/feed_layout.html @@ -12,6 +12,7 @@ {% if not suggested_books %}

{% trans "There are no books here right now! Try searching for a book to get started" %}

{% else %} + {% with active_book=request.GET.book %}
    @@ -28,8 +29,14 @@
      {% for book in shelf.books %} - - +
    • + {% include 'snippets/book_cover.html' with book=book size="medium" %}
    • @@ -45,22 +52,26 @@ {% for shelf in suggested_books %} {% with shelf_counter=forloop.counter %} {% for book in shelf.books %} -
      +
      +
      -

      - {% include 'snippets/book_titleby.html' with book=book %} -

      +
      +
      +

      {% include 'snippets/book_titleby.html' with book=book %}

      + {% include 'snippets/shelve_button/shelve_button.html' with book=book %} +
      +
      {% trans "Close" as button_text %} {% include 'snippets/toggle/toggle_button.html' with label=button_text controls_text="book" controls_uid=book.id class="delete" nonbutton=True pressed=True %}
      - {% include 'snippets/shelve_button/shelve_button.html' with book=book %} - {% active_shelf book as active_shelf %} - {% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %} - {% include 'snippets/progress_update.html' with readthrough=book.latest_readthrough %} - {% endif %} {% include 'snippets/create_status.html' with book=book %}
      @@ -68,6 +79,7 @@ {% endwith %} {% endfor %}
      + {% endwith %} {% endif %} {% if goal %} diff --git a/bookwyrm/templates/feed/suggested_users.html b/bookwyrm/templates/feed/suggested_users.html new file mode 100644 index 000000000..721ba3d9f --- /dev/null +++ b/bookwyrm/templates/feed/suggested_users.html @@ -0,0 +1,25 @@ +{% load i18n %} +{% load bookwyrm_tags %} +{% load humanize %} +
      + {% for user in suggested_users %} +
      +
      + + {% include 'snippets/avatar.html' with user=user large=True %} + {{ user.display_name|truncatechars:10 }} + @{{ user|username|truncatechars:8 }} + + {% include 'snippets/follow_button.html' with user=user minimal=True %} + {% if user.mutuals %} +

      + {% blocktrans with mutuals=user.mutuals|intcomma count counter=user.mutuals %}{{ mutuals }} follower you follow{% plural %}{{ mutuals }} followers you follow{% endblocktrans %} +

      + {% elif user.shared_books %} +

      {% blocktrans with shared_books=user.shared_books|intcomma count counter=user.shared_books %}{{ shared_books }} book on your shelves{% plural %}{{ shared_books }} books on your shelves{% endblocktrans %}

      + {% endif %} +
      +
      + {% endfor %} +
      + diff --git a/bookwyrm/templates/get_started/book_preview.html b/bookwyrm/templates/get_started/book_preview.html new file mode 100644 index 000000000..04d0c424d --- /dev/null +++ b/bookwyrm/templates/get_started/book_preview.html @@ -0,0 +1,14 @@ +{% load i18n %} +
      + {% include 'snippets/book_cover.html' with book=book %} +
      + diff --git a/bookwyrm/templates/get_started/books.html b/bookwyrm/templates/get_started/books.html new file mode 100644 index 000000000..ae2651220 --- /dev/null +++ b/bookwyrm/templates/get_started/books.html @@ -0,0 +1,57 @@ +{% extends 'get_started/layout.html' %} +{% load i18n %} + +{% block panel %} +
      +

      {% trans "What are you reading?" %}

      +
      +
      + + {% if request.GET.query and not book_results %} +

      {% blocktrans with query=request.GET.query %}No books found for "{{ query }}"{% endblocktrans %}. {% blocktrans %}You can add books when you start using {{ site_name }}.{% endblocktrans %}

      + {% endif %} +
      +
      + +
      +
      +
      + +
      + {% csrf_token %} +

      {% trans "Suggested Books" %}

      +
      + {% if book_results %} +
      +

      Search results

      +
      + {% for book in book_results %} + {% include 'get_started/book_preview.html' %} + {% endfor %} +
      +
      + {% endif %} + {% if popular_books %} +
      +

      + {% blocktrans %}Popular on {{ site_name }}{% endblocktrans %} +

      +
      + {% for book in popular_books %} + {% include 'get_started/book_preview.html' %} + {% endfor %} +
      +
      + {% endif %} + {% if not book_results and not popular_books %} +

      {% trans "No books found" %}

      + {% endif %} +
      + +
      +{% endblock %} + diff --git a/bookwyrm/templates/get_started/layout.html b/bookwyrm/templates/get_started/layout.html new file mode 100644 index 000000000..63c042895 --- /dev/null +++ b/bookwyrm/templates/get_started/layout.html @@ -0,0 +1,57 @@ +{% extends 'layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Welcome" %}{% endblock %} + +{% block content %} +{% with site_name=site.name %} + +{% endwith %} +{% endblock %} + + diff --git a/bookwyrm/templates/get_started/profile.html b/bookwyrm/templates/get_started/profile.html new file mode 100644 index 000000000..90cdb4104 --- /dev/null +++ b/bookwyrm/templates/get_started/profile.html @@ -0,0 +1,58 @@ +{% extends 'get_started/layout.html' %} +{% load i18n %} + +{% block panel %} +
      +

      {% trans "Create your profile" %}

      + {% if form.non_field_errors %} +

      {{ form.non_field_errors }}

      + {% endif %} +
      + {% csrf_token %} +
      +
      +
      + + + {% for error in form.name.errors %} +

      {{ error | escape }}

      + {% endfor %} +
      +
      + + + {% for error in form.summary.errors %} +

      {{ error | escape }}

      + {% endfor %} +
      +
      + +
      +
      + + {{ form.avatar }} + {% for error in form.avatar.errors %} +

      {{ error | escape }}

      + {% endfor %} +
      +
      +
      +
      + +
      +
      + + {% url 'directory' as path %} +

      {% trans "Your account will show up in the directory, and may be recommended to other BookWyrm users." %}

      +
      +
      +
      +
      +{% endblock %} + diff --git a/bookwyrm/templates/get_started/users.html b/bookwyrm/templates/get_started/users.html new file mode 100644 index 000000000..259f06d34 --- /dev/null +++ b/bookwyrm/templates/get_started/users.html @@ -0,0 +1,28 @@ +{% extends 'get_started/layout.html' %} +{% load i18n %} + +{% block panel %} +
      +

      {% trans "Who to follow" %}

      + +

      You can follow users on other BookWyrm instances and federated services like Mastodon.

      +
      +
      + + {% if request.GET.query and not user_results %} +

      {% blocktrans with query=request.GET.query %}No users found for "{{ query }}"{% endblocktrans %}

      + {% endif %} +
      +
      + +
      +
      + + {% include 'feed/suggested_users.html' with suggested_users=suggested_users %} +
      +{% endblock %} + diff --git a/bookwyrm/templates/goal.html b/bookwyrm/templates/goal.html index 5e967f41a..1e10144ff 100644 --- a/bookwyrm/templates/goal.html +++ b/bookwyrm/templates/goal.html @@ -55,11 +55,9 @@
      {% for book in goal.books %} -
      -
      - - {% include 'discover/small-book.html' with book=book.book rating=goal.ratings %} - + {% endfor %} diff --git a/bookwyrm/templates/host_meta.xml b/bookwyrm/templates/host_meta.xml new file mode 100644 index 000000000..d510ba713 --- /dev/null +++ b/bookwyrm/templates/host_meta.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/bookwyrm/templates/import.html b/bookwyrm/templates/import.html index 6b6febb19..a54051310 100644 --- a/bookwyrm/templates/import.html +++ b/bookwyrm/templates/import.html @@ -7,36 +7,43 @@ {% block content %}

      {% trans "Import Books" %}

      -
      + {% csrf_token %} -