diff --git a/.editorconfig b/.editorconfig index 58ba190d7..d102bc5ad 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,6 +23,7 @@ max_line_length = off [*.{csv,json,html,md,po,py,svg,tsv}] max_line_length = off +# ` ` at the end of a line is a line-break in markdown [*.{md,markdown}] trim_trailing_whitespace = false @@ -30,7 +31,9 @@ trim_trailing_whitespace = false indent_size = 2 max_line_length = off -[{package.json,yarn.lock}] +# Computer generated files +[{package.json,*.lock,*.mo}] indent_size = unset indent_style = unset max_line_length = unset +insert_final_newline = unset diff --git a/.env.example b/.env.dev.example similarity index 74% rename from .env.example rename to .env.dev.example index cf3705af0..5e605d744 100644 --- a/.env.example +++ b/.env.dev.example @@ -5,6 +5,7 @@ SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr" DEBUG=true DOMAIN=your.domain.here +#EMAIL=your@email.here ## Leave unset to allow all hosts # ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" @@ -26,13 +27,24 @@ POSTGRES_HOST=db MAX_STREAM_LENGTH=200 REDIS_ACTIVITY_HOST=redis_activity REDIS_ACTIVITY_PORT=6379 +#REDIS_ACTIVITY_PASSWORD=redispassword345 -# Celery config with redis broker +# Redis as celery broker +#REDIS_BROKER_PORT=6379 +#REDIS_BROKER_PASSWORD=redispassword123 CELERY_BROKER=redis://redis_broker:6379/0 CELERY_RESULT_BACKEND=redis://redis_broker:6379/0 +FLOWER_PORT=8888 +#FLOWER_USER=mouse +#FLOWER_PASSWORD=changeme + EMAIL_HOST="smtp.mailgun.org" EMAIL_PORT=587 EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true +EMAIL_USE_SSL=false + +# Set this to true when initializing certbot for domain, false when not +CERTBOT_INIT=false diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 000000000..0013bf9d2 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,50 @@ +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG=false + +DOMAIN=your.domain.here +EMAIL=your@email.here + +## Leave unset to allow all hosts +# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" + +OL_URL=https://openlibrary.org + +## Database backend to use. +## Default is postgres, sqlite is for dev quickstart only (NOT production!!!) +BOOKWYRM_DATABASE_BACKEND=postgres + +MEDIA_ROOT=images/ + +POSTGRES_PASSWORD=securedbpassword123 +POSTGRES_USER=fedireads +POSTGRES_DB=fedireads +POSTGRES_HOST=db + +# Redis activity stream manager +MAX_STREAM_LENGTH=200 +REDIS_ACTIVITY_HOST=redis_activity +REDIS_ACTIVITY_PORT=6379 +REDIS_ACTIVITY_PASSWORD=redispassword345 + +# Redis as celery broker +REDIS_BROKER_PORT=6379 +REDIS_BROKER_PASSWORD=redispassword123 +CELERY_BROKER=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0 +CELERY_RESULT_BACKEND=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0 + +FLOWER_PORT=8888 +FLOWER_USER=mouse +FLOWER_PASSWORD=changeme + +EMAIL_HOST="smtp.mailgun.org" +EMAIL_PORT=587 +EMAIL_HOST_USER=mail@your.domain.here +EMAIL_HOST_PASSWORD=emailpassword123 +EMAIL_USE_TLS=true +EMAIL_USE_SSL=false + +# Set this to true when initializing certbot for domain, false when not +CERTBOT_INIT=false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..b2cd33f89 --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +**/vendor/** diff --git a/.eslintrc.js b/.eslintrc.js index d39859f19..b65fe9885 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -6,5 +6,85 @@ module.exports = { "es6": true }, - "extends": "eslint:recommended" + "extends": "eslint:recommended", + + "rules": { + // Possible Errors + "no-async-promise-executor": "error", + "no-await-in-loop": "error", + "no-class-assign": "error", + "no-confusing-arrow": "error", + "no-const-assign": "error", + "no-dupe-class-members": "error", + "no-duplicate-imports": "error", + "no-template-curly-in-string": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-useless-rename": "error", + "require-atomic-updates": "error", + + // Best practices + "strict": "error", + "no-var": "error", + + // Stylistic Issues + "arrow-spacing": "error", + "capitalized-comments": [ + "warn", + "always", + { + "ignoreConsecutiveComments": true + }, + ], + "keyword-spacing": "error", + "lines-around-comment": [ + "error", + { + "beforeBlockComment": true, + "beforeLineComment": true, + "allowBlockStart": true, + "allowClassStart": true, + "allowObjectStart": true, + "allowArrayStart": true, + }, + ], + "no-multiple-empty-lines": [ + "error", + { + "max": 1, + }, + ], + "padded-blocks": [ + "error", + "never", + ], + "padding-line-between-statements": [ + "error", + { + // always before return + "blankLine": "always", + "prev": "*", + "next": "return", + }, + { + // always before block-like expressions + "blankLine": "always", + "prev": "*", + "next": "block-like", + }, + { + // always after variable declaration + "blankLine": "always", + "prev": [ "const", "let", "var" ], + "next": "*", + }, + { + // not necessary between variable declaration + "blankLine": "any", + "prev": [ "const", "let", "var" ], + "next": [ "const", "let", "var" ], + }, + ], + "space-before-blocks": "error", + } }; diff --git a/.github/workflows/lint-frontend.yaml b/.github/workflows/lint-frontend.yaml index 978bbbbe5..54cac04d0 100644 --- a/.github/workflows/lint-frontend.yaml +++ b/.github/workflows/lint-frontend.yaml @@ -3,12 +3,14 @@ name: Lint Frontend on: push: - branches: [ main, ci ] + branches: [ main, ci, frontend ] paths: - '.github/workflows/**' - 'static/**' + - '.eslintrc' + - '.stylelintrc' pull_request: - branches: [ main, ci ] + branches: [ main, ci, frontend ] jobs: lint: @@ -16,14 +18,21 @@ jobs: runs-on: ubuntu-20.04 steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it. - uses: actions/checkout@v2 - name: Install modules run: yarn + # See .stylelintignore for files that are not linted. - name: Run stylelint - run: yarn stylelint **/static/**/*.css --report-needless-disables --report-invalid-scope-disables + run: > + yarn stylelint bookwyrm/static/**/*.css \ + --report-needless-disables \ + --report-invalid-scope-disables + # See .eslintignore for files that are not linted. - name: Run ESLint - run: yarn eslint . --ext .js,.jsx,.ts,.tsx + run: > + yarn eslint bookwyrm/static \ + --ext .js,.jsx,.ts,.tsx diff --git a/.gitignore b/.gitignore index 71fa61bfa..cf88e9878 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ #Node tools /node_modules/ + +#nginx +nginx/default.conf diff --git a/.stylelintignore b/.stylelintignore index f456cb226..b2cd33f89 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -1,2 +1 @@ -bookwyrm/static/css/bulma.*.css* -bookwyrm/static/css/icons.css +**/vendor/** diff --git a/README.md b/README.md index e798fedf5..161f91b94 100644 --- a/README.md +++ b/README.md @@ -9,29 +9,17 @@ Social reading and reviewing, decentralized with ActivityPub - [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) +- [Set up Bookwyrm](#set-up-bookwyrm) ## Joining BookWyrm -BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) list. +BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://docs.joinbookwyrm.com/instances.html) list. + +You can request an invite by entering your email address at https://bookwyrm.social. + ## Contributing -There are many ways you can contribute to this project, regardless of your level of technical expertise. - -### Feedback and feature requests -Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues). - -### Code contributions -Code contributions are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out. - -If you have questions about the project or contributing, you can set up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min). - -### Translation -Do you speak a language besides English? BookWyrm needs localization! If you're comfortable using git and want to get into the code, there are [instructions](#working-with-translations-and-locale-files) on how to create and edit localization files. If you feel more comfortable working in a regular text editor and would prefer not to run the application, get in touch directly and we can figure out a system, like emailing a text file, that works best. - -### Financial Support -BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo). +See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions. ## About BookWyrm ### What it is and isn't @@ -43,7 +31,7 @@ BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks. ### Features -Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going! +Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/bookwyrm-social/bookwyrm/issues) to get the conversation going! - Posting about books - Compose reviews, with or without ratings, which are aggregated in the book page - Compose other kinds of statuses about books, such as: @@ -73,8 +61,8 @@ Since the project is still in its early stages, the features are growing every d Web backend - [Django](https://www.djangoproject.com/) web server - [PostgreSQL](https://www.postgresql.org/) database -- [ActivityPub](http://activitypub.rocks/) federation -- [Celery](http://celeryproject.org/) task queuing +- [ActivityPub](https://activitypub.rocks/) federation +- [Celery](https://docs.celeryproject.org/) task queuing - [Redis](https://redis.io/) task backend - [Redis (again)](https://redis.io/) activity stream manager @@ -89,246 +77,9 @@ Deployment - [Flower](https://github.com/mher/flower) celery monitoring - [Nginx](https://nginx.org/en/) HTTP server -## Setting up the developer environment - -Set up the environment file: - -``` bash -cp .env.example .env -``` - -For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain. - -You'll have to install the Docker and docker-compose. When you're ready, run: - -```bash -docker-compose build -docker-compose run --rm web python manage.py migrate -docker-compose run --rm web python manage.py initdb -docker-compose up -``` - -Once the build is complete, you can access the instance at `localhost:1333` - -### Editing static files -If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` command in order for your changes to have effect. You can do this by running: -``` bash -./bw-dev collectstatic -``` - -### Working with translations and locale files -Text in the html files are wrapped in translation tags (`{% trans %}` and `{% blocktrans %}`), and Django generates locale files for all the strings in which you can add translations for the text. You can find existing translations in the `locale/` directory. - -The application's language is set by a request header sent by your browser to the application, so to change the language of the application, you can change the default language requested by your browser. - -#### Adding a locale -To start translation into a language which is currently supported, run the django-admin `makemessages` command with the language code for the language you want to add (like `de` for German, or `en-gb` for British English): -``` bash -./bw-dev makemessages -l -``` - -#### Editing a locale -When you have a locale file, open the `django.po` in the directory for the language (for example, if you were adding German, `locale/de/LC_MESSAGES/django.po`. All the the text in the application will be shown in paired strings, with `msgid` as the original text, and `msgstr` as the translation (by default, this is set to an empty string, and will display the original text). - -Add your translations to the `msgstr` strings. As the messages in the application are updated, `gettext` will sometimes add best-guess fuzzy matched options for those translations. When a message is marked as fuzzy, it will not be used in the application, so be sure to remove it when you translate that line. - -When you're done, compile the locale by running: - -``` bash -./bw-dev compilemessages -``` - -You can add the `-l ` to only compile one language. When you refresh the application, you should see your translations at work. - -## Installing in Production - -This project is still young and isn't, at the moment, very stable, so please proceed with caution when running in production. - -### Server setup -- Get a domain name and set up DNS for your server -- Set your server up with appropriate firewalls for running a web application (this instruction set is tested against Ubuntu 20.04) -- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings -- Install Docker and docker-compose - -### Install and configure BookWyrm - -The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and individual changes to container config to enable things like SSL or regular backups. - -Instructions for running BookWyrm in production: - -- Get the application code: - `git clone git@github.com:mouse-reeve/bookwyrm.git` -- Switch to the `production` branch - `git checkout production` -- Create your environment variables file - `cp .env.example .env` - - Add your domain, email address, SMTP credentials - - Set a secure redis password and secret key - - Set a secure database password for postgres -- Update your nginx configuration in `nginx/default.conf` - - Replace `your-domain.com` with your domain name -- Run the application (this should also set up a Certbot ssl cert for your domain) with - `docker-compose up --build`, and make sure all the images build successfully -- When docker has built successfully, stop the process with `CTRL-C` -- Comment out the `command: certonly...` line in `docker-compose.yml` -- Run docker-compose in the background with: `docker-compose up -d` -- Initialize the database with: `./bw-dev initdb` -- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U ` and saves the backup to a safe location -- Get the application code: - `git clone git@github.com:mouse-reeve/bookwyrm.git` -- Switch to the `production` branch - `git checkout production` -- Create your environment variables file - `cp .env.example .env` - - Add your domain, email address, SMTP credentials - - Set a secure redis password and secret key - - Set a secure database password for postgres -- Update your nginx configuration in `nginx/default.conf` - - Replace `your-domain.com` with your domain name - - If you aren't using the `www` subdomain, remove the www.your-domain.com version of the domain from the `server_name` in the first server block in `nginx/default.conf` and remove the `-d www.${DOMAIN}` flag at the end of the `certbot` command in `docker-compose.yml`. - - If you are running another web-server on your host machine, you will need to follow the [reverse-proxy instructions](#running-bookwyrm-behind-a-reverse-proxy) -- Run the application (this should also set up a Certbot ssl cert for your domain) with - `docker-compose up --build`, and make sure all the images build successfully - - If you are running other services on your host machine, you may run into errors where services fail when attempting to bind to a port. - See the [troubleshooting guide](#port-conflicts) for advice on resolving this. -- When docker has built successfully, stop the process with `CTRL-C` -- Comment out the `command: certonly...` line in `docker-compose.yml`, and uncomment the following line (`command: renew ...`) so that the certificate will be automatically renewed. -- Uncomment the https redirect and `server` block in `nginx/default.conf` (lines 17-48). -- Run docker-compose in the background with: `docker-compose up -d` -- Initialize the database with: `./bw-dev initdb` - -Congrats! You did it, go to your domain and enjoy the fruits of your labors. - -### Configure your instance -- Register a user account in the application UI -- Make your account a superuser (warning: do *not* use django's `createsuperuser` command) - - On your server, open the django shell - `./bw-dev shell` - - Load your user and make it a superuser - ```python - from bookwyrm import models - user = models.User.objects.get(id=1) - user.is_staff = True - user.is_superuser = True - user.save() - ``` - - Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance - -### Backups - -BookWyrm's db service dumps a backup copy of its database to its `/backups` directory daily at midnight UTC. -Backups are named `backup__%Y-%m-%d.sql`. - -The db service has an optional script for periodically pruning the backups directory so that all recent daily backups are kept, but for older backups, only weekly or monthly backups are kept. -To enable this script: -- Uncomment the final line in `postgres-docker/cronfile` -- rebuild your instance `docker-compose up --build` - -You can copy backups from the backups volume to your host machine with `docker cp`: -- Run `docker-compose ps` to confirm the db service's full name (it's probably `bookwyrm_db_1`. -- Run `docker cp :/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 - -BookWyrm has multiple services that run on their default ports. -This means that, depending on what else you are running on your host machine, you may run into errors when building or running BookWyrm when attempts to bind to those ports fail. - -If this occurs, you will need to change your configuration to run services on different ports. -This may require one or more changes the following files: -- `docker-compose.yml` -- `nginx/default.conf` -- `.env` (You create this file yourself during setup) - -E.g., If you need Redis to run on a different port: -- In `docker-compose.yml`: - - In `services` -> `redis` -> `command`, add `--port YOUR_PORT` to the command - - In `services` -> `redis` -> `ports`, change `6379:6379` to your port -- In `.env`, update `REDIS_PORT` - -If you are already running a web-server on your machine, you will need to set up a reverse-proxy. - -#### Running BookWyrm Behind a Reverse-Proxy - -If you are running another web-server on your machine, you should have it handle proxying web requests to BookWyrm. - -The default BookWyrm configuration already has an nginx server that proxies requests to the django app that handles SSL and directly serves static files. -The static files are stored in a Docker volume that several BookWyrm services access, so it is not recommended to remove this server completely. - -To run BookWyrm behind a reverse-proxy, make the following changes: -- In `nginx/default.conf`: - - Comment out the two default servers - - Uncomment the server labeled Reverse-Proxy server - - Replace `your-domain.com` with your domain name -- In `docker-compose.yml`: - - In `services` -> `nginx` -> `ports`, comment out the default ports and add `- 8001:8001` - - In `services` -> `nginx` -> `volumes`, comment out the two volumes that begin `./certbot/` - - In `services`, comment out the `certbot` service - -At this point, you can follow, the [setup](#server-setup) instructions as listed. -Once docker is running, you can access your BookWyrm instance at `http://localhost:8001` (**NOTE:** your server is not accessible over `https`). - -Steps for setting up a reverse-proxy are server dependent. - -##### Nginx - -Before you can set up nginx, you will need to locate your nginx configuration directory, which is dependent on your platform and how you installed nginx. -See [nginx's guide](http://nginx.org/en/docs/beginners_guide.html) for details. - -To set up your server: -- In you `nginx.conf` file, ensure that `include servers/*;` isn't commented out. -- In your nginx `servers` directory, create a new file named after your domain containing the following information: - ```nginx - server { - server_name your-domain.com www.your-domain.com; - - location / { - proxy_pass http://localhost:8000; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - - location /images/ { - proxy_pass http://localhost:8001; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - - location /static/ { - proxy_pass http://localhost:8001; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - - listen [::]:80 ssl; - listen 80 ssl; - } - ``` -- run `sudo certbot run --nginx --email YOUR_EMAIL -d your-domain.com -d www.your-domain.com` -- restart nginx - -If everything worked correctly, your BookWyrm instance should now be externally accessible. - ## Book data The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written. -There are three concepts in the book data model: -- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition` -- `Work`, the theoretical umbrella concept of a book that encompasses every edition of the book, and -- `Edition`, a concrete, actually published version of a book - -Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page. +## Set up Bookwyrm +The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html). diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index c67c5dcad..d363fbd53 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -5,7 +5,7 @@ import sys from .base_activity import ActivityEncoder, Signature, naive_parse from .base_activity import Link, Mention from .base_activity import ActivitySerializerError, resolve_remote_id -from .image import Image +from .image import Document, Image from .note import Note, GeneratedNote, Article, Comment, Quotation from .note import Review, Rating from .note import Tombstone diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 452f61e03..dd2795bb1 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -52,10 +52,14 @@ def naive_parse(activity_objects, activity_json, serializer=None): if activity_json.get("publicKeyPem"): # ugh activity_json["type"] = "PublicKey" + + activity_type = activity_json.get("type") try: - activity_type = activity_json["type"] serializer = activity_objects[activity_type] except KeyError as e: + # we know this exists and that we can't handle it + if activity_type in ["Question"]: + return None raise ActivitySerializerError(e) return serializer(activity_objects=activity_objects, **activity_json) @@ -111,7 +115,7 @@ class ActivityObject: and hasattr(model, "ignore_activity") and model.ignore_activity(self) ): - raise ActivitySerializerError() + return None # check for an existing instance instance = instance or model.find_existing(self.serialize()) diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 7e552b0a8..c5b896e34 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -3,7 +3,7 @@ from dataclasses import dataclass, field from typing import List from .base_activity import ActivityObject -from .image import Image +from .image import Document @dataclass(init=False) @@ -11,6 +11,7 @@ class Book(ActivityObject): """ serializes an edition or work, abstract """ title: str + lastEditedBy: str = None sortTitle: str = "" subtitle: str = "" description: str = "" @@ -28,7 +29,7 @@ class Book(ActivityObject): librarythingKey: str = "" goodreadsKey: str = "" - cover: Image = None + cover: Document = None type: str = "Book" @@ -64,6 +65,7 @@ class Author(ActivityObject): """ author of a book """ name: str + lastEditedBy: str = None born: str = None died: str = None aliases: List[str] = field(default_factory=lambda: []) diff --git a/bookwyrm/activitypub/image.py b/bookwyrm/activitypub/image.py index 931de977b..a7120ce4b 100644 --- a/bookwyrm/activitypub/image.py +++ b/bookwyrm/activitypub/image.py @@ -4,10 +4,17 @@ from .base_activity import ActivityObject @dataclass(init=False) -class Image(ActivityObject): - """ image block """ +class Document(ActivityObject): + """ a document """ url: str name: str = "" type: str = "Document" id: str = None + + +@dataclass(init=False) +class Image(Document): + """ an image """ + + type: str = "Image" diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index a739eafa1..e1a42958c 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -4,7 +4,7 @@ from typing import Dict, List from django.apps import apps from .base_activity import ActivityObject, Link -from .image import Image +from .image import Document @dataclass(init=False) @@ -32,7 +32,7 @@ class Note(ActivityObject): inReplyTo: str = "" summary: str = "" tag: List[Link] = field(default_factory=lambda: []) - attachment: List[Image] = field(default_factory=lambda: []) + attachment: List[Document] = field(default_factory=lambda: []) sensitive: bool = False type: str = "Note" diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index c3c84ee5b..090beea5f 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -1,4 +1,4 @@ -""" undo wrapper activity """ +""" activities that do things """ from dataclasses import dataclass, field from typing import List from django.apps import apps @@ -9,23 +9,25 @@ from .ordered_collection import CollectionItem @dataclass(init=False) class Verb(ActivityObject): - """generic fields for activities - maybe an unecessary level of - abstraction but w/e""" + """generic fields for activities """ actor: str object: ActivityObject def action(self): - """ usually we just want to save, this can be overridden as needed """ - self.object.to_model() + """ usually we just want to update and save """ + # self.object may return None if the object is invalid in an expected way + # ie, Question type + if self.object: + self.object.to_model() @dataclass(init=False) class Create(Verb): """ Create activity """ - to: List - cc: List + to: List[str] + cc: List[str] = field(default_factory=lambda: []) signature: Signature = None type: str = "Create" @@ -34,26 +36,38 @@ class Create(Verb): class Delete(Verb): """ Create activity """ - to: List - cc: List + to: List[str] + cc: List[str] = field(default_factory=lambda: []) type: str = "Delete" def action(self): """ find and delete the activity object """ - obj = self.object.to_model(save=False, allow_create=False) - obj.delete() + if not self.object: + return + + if isinstance(self.object, str): + # Deleted users are passed as strings. Not wild about this fix + model = apps.get_model("bookwyrm.User") + obj = model.find_existing_by_remote_id(self.object) + else: + obj = self.object.to_model(save=False, allow_create=False) + + if obj: + obj.delete() + # if we can't find it, we don't need to delete it because we don't have it @dataclass(init=False) class Update(Verb): """ Update activity """ - to: List + to: List[str] type: str = "Update" def action(self): """ update a model instance from the dataclass """ - self.object.to_model(allow_create=False) + if self.object: + self.object.to_model(allow_create=False) @dataclass(init=False) @@ -162,7 +176,8 @@ class Remove(Add): def action(self): """ find and remove the activity object """ obj = self.object.to_model(save=False, allow_create=False) - obj.delete() + if obj: + obj.delete() @dataclass(init=False) diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 2483cc62b..2fe5d825c 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -219,6 +219,12 @@ def dict_from_mappings(data, mappings): def get_data(url, params=None): """ wrapper for request.get """ + # check if the url is blocked + if models.FederatedServer.is_blocked(url): + raise ConnectorException( + "Attempting to load data from blocked url: {:s}".format(url) + ) + try: resp = requests.get( url, diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index b159a89ef..1f1f1a3b5 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -3,7 +3,7 @@ import datetime from collections import defaultdict from django import forms -from django.forms import ModelForm, PasswordInput, widgets +from django.forms import ModelForm, PasswordInput, widgets, ChoiceField from django.forms.widgets import Textarea from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -150,6 +150,12 @@ class LimitedEditUserForm(CustomForm): help_texts = {f: None for f in fields} +class UserGroupForm(CustomForm): + class Meta: + model = models.User + fields = ["groups"] + + class TagForm(CustomForm): class Meta: model = models.Tag @@ -281,3 +287,26 @@ class ReportForm(CustomForm): class Meta: model = models.Report fields = ["user", "reporter", "statuses", "note"] + + +class ServerForm(CustomForm): + class Meta: + model = models.FederatedServer + exclude = ["remote_id"] + + +class SortListForm(forms.Form): + sort_by = ChoiceField( + choices=( + ("order", _("List Order")), + ("title", _("Book Title")), + ("rating", _("Rating")), + ), + label=_("Sort By"), + ) + direction = ChoiceField( + choices=( + ("ascending", _("Ascending")), + ("descending", _("Descending")), + ), + ) diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index d6101c877..a86a1652e 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType -from bookwyrm.models import Connector, SiteSettings, User +from bookwyrm.models import Connector, FederatedServer, SiteSettings, User from bookwyrm.settings import DOMAIN @@ -107,6 +107,16 @@ def init_connectors(): ) +def init_federated_servers(): + """ big no to nazis """ + built_in_blocks = ["gab.ai", "gab.com"] + for server in built_in_blocks: + FederatedServer.objects.create( + server_name=server, + status="blocked", + ) + + def init_settings(): SiteSettings.objects.create() @@ -118,4 +128,5 @@ class Command(BaseCommand): init_groups() init_permissions() init_connectors() + init_federated_servers() init_settings() diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py index 06ca5f075..4cd2036a0 100644 --- a/bookwyrm/management/commands/populate_streams.py +++ b/bookwyrm/management/commands/populate_streams.py @@ -17,7 +17,7 @@ def populate_streams(): ) for user in users: for stream in activitystreams.streams.values(): - stream.populate_stream(user) + stream.populate_streams(user) class Command(BaseCommand): diff --git a/bookwyrm/migrations/0063_auto_20210407_1827.py b/bookwyrm/migrations/0063_auto_20210407_1827.py new file mode 100644 index 000000000..0bd0f2ae4 --- /dev/null +++ b/bookwyrm/migrations/0063_auto_20210407_1827.py @@ -0,0 +1,37 @@ +# Generated by Django 3.1.6 on 2021-04-07 18:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0062_auto_20210407_1545"), + ] + + operations = [ + migrations.AddField( + model_name="federatedserver", + name="notes", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="federatedserver", + name="application_type", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="federatedserver", + name="application_version", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="federatedserver", + name="status", + field=models.CharField( + choices=[("federated", "Federated"), ("blocked", "Blocked")], + default="federated", + max_length=255, + ), + ), + ] diff --git a/bookwyrm/migrations/0064_merge_20210410_1633.py b/bookwyrm/migrations/0064_merge_20210410_1633.py new file mode 100644 index 000000000..77ad541e9 --- /dev/null +++ b/bookwyrm/migrations/0064_merge_20210410_1633.py @@ -0,0 +1,13 @@ +# Generated by Django 3.1.8 on 2021-04-10 16:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0063_auto_20210408_1556"), + ("bookwyrm", "0063_auto_20210407_1827"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0065_merge_20210411_1702.py b/bookwyrm/migrations/0065_merge_20210411_1702.py new file mode 100644 index 000000000..2bdc425dc --- /dev/null +++ b/bookwyrm/migrations/0065_merge_20210411_1702.py @@ -0,0 +1,13 @@ +# Generated by Django 3.1.8 on 2021-04-11 17:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0064_auto_20210408_2208"), + ("bookwyrm", "0064_merge_20210410_1633"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0066_user_deactivation_reason.py b/bookwyrm/migrations/0066_user_deactivation_reason.py new file mode 100644 index 000000000..bb3173a7c --- /dev/null +++ b/bookwyrm/migrations/0066_user_deactivation_reason.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.8 on 2021-04-12 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0065_merge_20210411_1702"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("self_deletion", "Self Deletion"), + ("moderator_deletion", "Moderator Deletion"), + ("domain_block", "Domain Block"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0067_denullify_list_item_order.py b/bookwyrm/migrations/0067_denullify_list_item_order.py new file mode 100644 index 000000000..51e28371b --- /dev/null +++ b/bookwyrm/migrations/0067_denullify_list_item_order.py @@ -0,0 +1,30 @@ +from django.db import migrations + + +def forwards_func(apps, schema_editor): + # Set all values for ListItem.order + BookList = apps.get_model("bookwyrm", "List") + db_alias = schema_editor.connection.alias + for book_list in BookList.objects.using(db_alias).all(): + for i, item in enumerate(book_list.listitem_set.order_by("id"), 1): + item.order = i + item.save() + + +def reverse_func(apps, schema_editor): + # null all values for ListItem.order + BookList = apps.get_model("bookwyrm", "List") + db_alias = schema_editor.connection.alias + for book_list in BookList.objects.using(db_alias).all(): + for item in book_list.listitem_set.order_by("id"): + item.order = None + item.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0066_user_deactivation_reason"), + ] + + operations = [migrations.RunPython(forwards_func, reverse_func)] diff --git a/bookwyrm/migrations/0068_ordering_for_list_items.py b/bookwyrm/migrations/0068_ordering_for_list_items.py new file mode 100644 index 000000000..fa64f13c0 --- /dev/null +++ b/bookwyrm/migrations/0068_ordering_for_list_items.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.6 on 2021-04-08 16:15 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0067_denullify_list_item_order"), + ] + + operations = [ + migrations.AlterField( + model_name="listitem", + name="order", + field=bookwyrm.models.fields.IntegerField(), + ), + migrations.AlterUniqueTogether( + name="listitem", + unique_together={("order", "book_list"), ("book", "book_list")}, + ), + ] diff --git a/bookwyrm/migrations/0069_auto_20210422_1604.py b/bookwyrm/migrations/0069_auto_20210422_1604.py new file mode 100644 index 000000000..6591e7b92 --- /dev/null +++ b/bookwyrm/migrations/0069_auto_20210422_1604.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.8 on 2021-04-22 16:04 + +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0068_ordering_for_list_items"), + ] + + operations = [ + migrations.AlterField( + model_name="author", + name="last_edited_by", + field=bookwyrm.models.fields.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="book", + name="last_edited_by", + field=bookwyrm.models.fields.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 714dc485a..82a45ac86 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -148,13 +148,17 @@ class ActivitypubMixin: mentions = self.recipients if hasattr(self, "recipients") else [] # we always send activities to explicitly mentioned users' inboxes - recipients = [u.inbox for u in mentions or []] + recipients = [u.inbox for u in mentions or [] if not u.local] # unless it's a dm, all the followers should receive the activity if privacy != "direct": # we will send this out to a subset of all remote users - queryset = user_model.objects.filter( - local=False, + queryset = ( + user_model.viewer_aware_objects(user) + .filter( + local=False, + ) + .distinct() ) # filter users first by whether they're using the desired software # this lets us send book updates only to other bw servers @@ -175,7 +179,7 @@ class ActivitypubMixin: "inbox", flat=True ) recipients += list(shared_inboxes) + list(inboxes) - return recipients + return list(set(recipients)) def to_activity_dataclass(self): """ convert from a model to an activity """ @@ -193,7 +197,7 @@ class ObjectMixin(ActivitypubMixin): def save(self, *args, created=None, **kwargs): """ broadcast created/updated/deleted objects as appropriate """ broadcast = kwargs.get("broadcast", True) - # this bonus kwarg woul cause an error in the base save method + # this bonus kwarg would cause an error in the base save method if "broadcast" in kwargs: del kwargs["broadcast"] @@ -359,6 +363,10 @@ class CollectionItemMixin(ActivitypubMixin): activity_serializer = activitypub.CollectionItem + def broadcast(self, activity, sender, software="bookwyrm"): + """ only send book collection updates to other bookwyrm instances """ + super().broadcast(activity, sender, software=software) + @property def privacy(self): """ inherit the privacy of the list, or direct if pending """ @@ -371,6 +379,9 @@ class CollectionItemMixin(ActivitypubMixin): def recipients(self): """ the owner of the list is a direct recipient """ collection_field = getattr(self, self.collection_field) + if collection_field.user.local: + # don't broadcast to yourself + return [] return [collection_field.user] def save(self, *args, broadcast=True, **kwargs): @@ -386,11 +397,11 @@ class CollectionItemMixin(ActivitypubMixin): activity = self.to_add_activity(self.user) self.broadcast(activity, self.user) - def delete(self, *args, **kwargs): + def delete(self, *args, broadcast=True, **kwargs): """ broadcast a remove activity """ activity = self.to_remove_activity(self.user) super().delete(*args, **kwargs) - if self.user.local: + if self.user.local and broadcast: self.broadcast(activity, self.user) def to_add_activity(self, user): @@ -524,7 +535,7 @@ def to_ordered_collection_page( """ serialize and pagiante a queryset """ paginated = Paginator(queryset, PAGE_LENGTH) - activity_page = paginated.page(page) + activity_page = paginated.get_page(page) if id_only: items = [s.remote_id for s in activity_page.object_list] else: diff --git a/bookwyrm/models/attachment.py b/bookwyrm/models/attachment.py index 8d2238a14..eaeca11e2 100644 --- a/bookwyrm/models/attachment.py +++ b/bookwyrm/models/attachment.py @@ -33,4 +33,4 @@ class Image(Attachment): ) caption = fields.TextField(null=True, blank=True, activitypub_field="name") - activity_serializer = activitypub.Image + activity_serializer = activitypub.Document diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index cb2fc851e..261c96868 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -31,6 +31,36 @@ class BookWyrmModel(models.Model): """ how to link to this object in the local app """ return self.get_remote_id().replace("https://%s" % DOMAIN, "") + def visible_to_user(self, viewer): + """ is a user authorized to view an object? """ + # make sure this is an object with privacy owned by a user + if not hasattr(self, "user") or not hasattr(self, "privacy"): + return None + + # viewer can't see it if the object's owner blocked them + if viewer in self.user.blocks.all(): + return False + + # you can see your own posts and any public or unlisted posts + if viewer == self.user or self.privacy in ["public", "unlisted"]: + return True + + # you can see the followers only posts of people you follow + if ( + self.privacy == "followers" + and self.user.followers.filter(id=viewer.id).first() + ): + return True + + # you can see dms you are tagged in + if hasattr(self, "mention_users"): + if ( + self.privacy == "direct" + and self.mention_users.filter(id=viewer.id).first() + ): + return True + return False + @receiver(models.signals.post_save) # pylint: disable=unused-argument diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index a6824c0ad..5280c7aaf 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -26,7 +26,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel): max_length=255, blank=True, null=True, deduplication_field=True ) - last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True) + last_edited_by = fields.ForeignKey( + "User", + on_delete=models.PROTECT, + null=True, + ) class Meta: """ can't initialize this model, that wouldn't make sense """ diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py index 8f7d903e4..aa2b2f6af 100644 --- a/bookwyrm/models/federated_server.py +++ b/bookwyrm/models/federated_server.py @@ -1,17 +1,51 @@ """ connections to external ActivityPub servers """ +from urllib.parse import urlparse from django.db import models from .base_model import BookWyrmModel +FederationStatus = models.TextChoices( + "Status", + [ + "federated", + "blocked", + ], +) + class FederatedServer(BookWyrmModel): """ store which servers we federate with """ server_name = models.CharField(max_length=255, unique=True) - # federated, blocked, whatever else - status = models.CharField(max_length=255, default="federated") + status = models.CharField( + max_length=255, default="federated", choices=FederationStatus.choices + ) # is it mastodon, bookwyrm, etc - application_type = models.CharField(max_length=255, null=True) - application_version = models.CharField(max_length=255, null=True) + application_type = models.CharField(max_length=255, null=True, blank=True) + application_version = models.CharField(max_length=255, null=True, blank=True) + notes = models.TextField(null=True, blank=True) + def block(self): + """ block a server """ + self.status = "blocked" + self.save() -# TODO: blocked servers + # deactivate all associated users + self.user_set.filter(is_active=True).update( + is_active=False, deactivation_reason="domain_block" + ) + + def unblock(self): + """ unblock a server """ + self.status = "federated" + self.save() + + self.user_set.filter(deactivation_reason="domain_block").update( + is_active=True, deactivation_reason=None + ) + + @classmethod + def is_blocked(cls, url): + """ look up if a domain is blocked """ + url = urlparse(url) + domain = url.netloc + return cls.objects.filter(server_name=domain, status="blocked").exists() diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index e034d59ee..a1b2035b1 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): return [i.remote_id for i in value.all()] def field_from_activity(self, value): - items = [] if value is None or value is MISSING: - return [] + return None + if not isinstance(value, list): + # If this is a link, we currently aren't doing anything with it + return None + items = [] for remote_id in value: try: validate_remote_id(remote_id) @@ -336,7 +339,7 @@ def image_serializer(value, alt): else: return None url = "https://%s%s" % (DOMAIN, url) - return activitypub.Image(url=url, name=alt) + return activitypub.Document(url=url, name=alt) class ImageField(ActivitypubFieldMixin, models.ImageField): diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 4d6b53cde..639f84027 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -47,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel): @property def collection_queryset(self): """ list of books for this shelf, overrides OrderedCollectionMixin """ - return self.books.filter(listitem__approved=True).all().order_by("listitem") + return self.books.filter(listitem__approved=True).order_by("listitem") class Meta: """ default sorting """ @@ -67,7 +67,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel): ) notes = fields.TextField(blank=True, null=True) approved = models.BooleanField(default=True) - order = fields.IntegerField(blank=True, null=True) + order = fields.IntegerField() endorsement = models.ManyToManyField("User", related_name="endorsers") activity_serializer = activitypub.ListItem @@ -93,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel): ) class Meta: - """ an opinionated constraint! you can't put a book on a list twice """ - - unique_together = ("book", "book_list") + # A book may only be placed into a list once, and each order in the list may be used only + # once + unique_together = (("book", "book_list"), ("order", "book_list")) ordering = ("-created_date",) diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 998d7bed5..3f849597a 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -50,11 +50,10 @@ class UserRelationship(BookWyrmModel): ), ] - def get_remote_id(self, status=None): # pylint: disable=arguments-differ + def get_remote_id(self): """ use shelf identifier in remote_id """ - status = status or "follows" base_path = self.user_subject.remote_id - return "%s#%s/%d" % (base_path, status, self.id) + return "%s#follows/%d" % (base_path, self.id) class UserFollows(ActivityMixin, UserRelationship): @@ -102,12 +101,15 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): def save(self, *args, broadcast=True, **kwargs): """ make sure the follow or block relationship doesn't already exist """ - # don't create a request if a follow already exists + # if there's a request for a follow that already exists, accept it + # without changing the local database state if UserFollows.objects.filter( user_subject=self.user_subject, user_object=self.user_object, ).exists(): - raise IntegrityError() + self.accept(broadcast_only=True) + return + # blocking in either direction is a no-go if UserBlocks.objects.filter( Q( @@ -138,16 +140,25 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): notification_type=notification_type, ) - def accept(self): + def get_accept_reject_id(self, status): + """ get id for sending an accept or reject of a local user """ + + base_path = self.user_object.remote_id + return "%s#%s/%d" % (base_path, status, self.id or 0) + + def accept(self, broadcast_only=False): """ turn this request into the real deal""" user = self.user_object if not self.user_subject.local: activity = activitypub.Accept( - id=self.get_remote_id(status="accepts"), + id=self.get_accept_reject_id(status="accepts"), actor=self.user_object.remote_id, object=self.to_activity(), ).serialize() self.broadcast(activity, user) + if broadcast_only: + return + with transaction.atomic(): UserFollows.from_request(self) self.delete() @@ -156,7 +167,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): """ generate a Reject for this follow request """ if self.user_object.local: activity = activitypub.Reject( - id=self.get_remote_id(status="rejects"), + id=self.get_accept_reject_id(status="rejects"), actor=self.user_object.remote_id, object=self.to_activity(), ).serialize() diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 5bbb84b9b..d37668dd7 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -48,7 +48,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): @property def collection_queryset(self): """ list of books for this shelf, overrides OrderedCollectionMixin """ - return self.books.all().order_by("shelfbook") + return self.books.order_by("shelfbook") def get_remote_id(self): """ shelf identifier instead of id """ diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index c519f76c9..0f98c82dd 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -24,6 +24,16 @@ from .federated_server import FederatedServer from . import fields, Review +DeactivationReason = models.TextChoices( + "DeactivationReason", + [ + "self_deletion", + "moderator_deletion", + "domain_block", + ], +) + + class User(OrderedCollectionPageMixin, AbstractUser): """ a user who wants to read books """ @@ -111,6 +121,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): default=str(pytz.utc), max_length=255, ) + deactivation_reason = models.CharField( + max_length=255, choices=DeactivationReason.choices, null=True, blank=True + ) name_field = "username" property_fields = [("following_link", "following")] @@ -132,13 +145,18 @@ class User(OrderedCollectionPageMixin, AbstractUser): return self.name return self.localname or self.username + @property + def deleted(self): + """ for consistent naming """ + return not self.is_active + activity_serializer = activitypub.Person @classmethod def viewer_aware_objects(cls, viewer): """ the user queryset filtered for the context of the logged in user """ queryset = cls.objects.filter(is_active=True) - if viewer.is_authenticated: + if viewer and viewer.is_authenticated: queryset = queryset.exclude(blocks=viewer) return queryset @@ -192,6 +210,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): def to_activity(self, **kwargs): """override default AP serializer to add context object idk if this is the best way to go about this""" + if not self.is_active: + return self.remote_id + activity_object = super().to_activity(**kwargs) activity_object["@context"] = [ "https://www.w3.org/ns/activitystreams", @@ -270,6 +291,12 @@ class User(OrderedCollectionPageMixin, AbstractUser): editable=False, ).save(broadcast=False) + def delete(self, *args, **kwargs): + """ deactivate rather than delete a user """ + self.is_active = False + # skip the logic in this class's save() + super().save(*args, **kwargs) + @property def local_path(self): """ this model doesn't inherit bookwyrm model, so here we are """ diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 845f81c46..7ea8c5950 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -24,7 +24,8 @@ EMAIL_HOST = env("EMAIL_HOST") EMAIL_PORT = env("EMAIL_PORT", 587) EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = env("EMAIL_USE_TLS", True) +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True) +EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False) DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN")) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -97,6 +98,7 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application" # redis/activity streams settings REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost") REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379) +REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None) MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200)) STREAMS = ["home", "local", "federated"] @@ -165,7 +167,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.0/howto/static-files/ +# https://docs.djangoproject.com/en/3.1/howto/static-files/ PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) STATIC_URL = "/static/" diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/bookwyrm.css similarity index 72% rename from bookwyrm/static/css/format.css rename to bookwyrm/static/css/bookwyrm.css index a01aff827..b4abd6907 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -1,9 +1,13 @@ html { scroll-behavior: smooth; - scroll-padding-top: 20%; } -/* --- --- */ +body { + min-height: 100vh; + display: flex; + flex-direction: column; +} + .image { overflow: hidden; } @@ -25,17 +29,8 @@ html { 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 --- */ +/** Shelving + ******************************************************************************/ /** @todo Replace icons with SVG symbols. @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ @@ -45,7 +40,9 @@ html { margin-left: 0.5em; } -/* --- TOGGLES --- */ +/** Toggles + ******************************************************************************/ + .toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true]:hover { background-color: hsl(171, 100%, 41%); @@ -57,12 +54,8 @@ html { display: none; } -.hidden { - display: none !important; -} - -.hidden.transition-y, -.hidden.transition-x { +.transition-x.is-hidden, +.transition-y.is-hidden { display: block !important; visibility: hidden !important; height: 0; @@ -71,16 +64,18 @@ html { padding: 0; } +.transition-x, .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; +} + +.transition-y { + transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom; } @media (prefers-reduced-motion: reduce) { @@ -121,7 +116,9 @@ html { content: '\e9d7'; } -/* --- BOOK COVERS --- */ +/** Book covers + ******************************************************************************/ + .cover-container { height: 250px; width: max-content; @@ -186,7 +183,9 @@ html { padding: 0.1em; } -/* --- AVATAR --- */ +/** Avatars + ******************************************************************************/ + .avatar { vertical-align: middle; display: inline; @@ -202,25 +201,57 @@ html { min-height: 96px; } -/* --- QUOTES --- */ -.quote blockquote { +/** Statuses: Quotes + * + * \e906: icon-quote-open + * \e905: icon-quote-close + * + * The `content` class on the blockquote allows to apply styles to markdown + * generated HTML in the quote: https://bulma.io/documentation/elements/content/ + * + * ```html + *
+ *
+ * User generated quote in markdown… + *
+ * + *

Book Title by Author

+ *
+ * ``` + ******************************************************************************/ + +.quote > blockquote { position: relative; padding-left: 2em; } -.quote blockquote::before, -.quote blockquote::after { +.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; } + +/* States + ******************************************************************************/ + +/* "disabled" for non-buttons */ + +.is-disabled { + background-color: #dbdbdb; + border-color: #dbdbdb; + box-shadow: none; + color: #7a7a7a; + opacity: 0.5; + cursor: not-allowed; +} diff --git a/bookwyrm/static/css/bulma.css.map b/bookwyrm/static/css/vendor/bulma.css.map similarity index 100% rename from bookwyrm/static/css/bulma.css.map rename to bookwyrm/static/css/vendor/bulma.css.map diff --git a/bookwyrm/static/css/bulma.min.css b/bookwyrm/static/css/vendor/bulma.min.css similarity index 100% rename from bookwyrm/static/css/bulma.min.css rename to bookwyrm/static/css/vendor/bulma.min.css diff --git a/bookwyrm/static/css/icons.css b/bookwyrm/static/css/vendor/icons.css similarity index 86% rename from bookwyrm/static/css/icons.css rename to bookwyrm/static/css/vendor/icons.css index 9915ecd18..c78af145d 100644 --- a/bookwyrm/static/css/icons.css +++ b/bookwyrm/static/css/vendor/icons.css @@ -1,10 +1,13 @@ + +/** @todo Replace icons with SVG symbols. + @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ @font-face { font-family: 'icomoon'; - src: url('fonts/icomoon.eot?n5x55'); - src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'), - url('fonts/icomoon.ttf?n5x55') format('truetype'), - url('fonts/icomoon.woff?n5x55') format('woff'), - url('fonts/icomoon.svg?n5x55#icomoon') format('svg'); + 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; diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js new file mode 100644 index 000000000..485daf15b --- /dev/null +++ b/bookwyrm/static/js/bookwyrm.js @@ -0,0 +1,285 @@ +/* exported BookWyrm */ +/* globals TabGroup */ + +let BookWyrm = new class { + constructor() { + this.initOnDOMLoaded(); + this.initReccuringTasks(); + this.initEventListeners(); + } + + initEventListeners() { + document.querySelectorAll('[data-controls]') + .forEach(button => button.addEventListener( + 'click', + this.toggleAction.bind(this)) + ); + + document.querySelectorAll('.interaction') + .forEach(button => button.addEventListener( + 'submit', + this.interact.bind(this)) + ); + + document.querySelectorAll('.hidden-form input') + .forEach(button => button.addEventListener( + 'change', + this.revealForm.bind(this)) + ); + + document.querySelectorAll('[data-back]') + .forEach(button => button.addEventListener( + 'click', + this.back) + ); + } + + /** + * Execute code once the DOM is loaded. + */ + initOnDOMLoaded() { + window.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('.tab-group') + .forEach(tabs => new TabGroup(tabs)); + }); + } + + /** + * Execute recurring tasks. + */ + initReccuringTasks() { + // Polling + document.querySelectorAll('[data-poll]') + .forEach(liveArea => this.polling(liveArea)); + } + + /** + * Go back in browser history. + * + * @param {Event} event + * @return {undefined} + */ + back(event) { + event.preventDefault(); + history.back(); + } + + /** + * Update a counter with recurring requests to the API + * The delay is slightly randomized and increased on each cycle. + * + * @param {Object} counter - DOM node + * @param {int} delay - frequency for polling in ms + * @return {undefined} + */ + polling(counter, delay) { + const bookwyrm = this; + + delay = delay || 10000; + delay += (Math.random() * 1000); + + setTimeout(function() { + fetch('/api/updates/' + counter.dataset.poll) + .then(response => response.json()) + .then(data => bookwyrm.updateCountElement(counter, data)); + + bookwyrm.polling(counter, delay * 1.25); + }, delay, counter); + } + + /** + * Update a counter. + * + * @param {object} counter - DOM node + * @param {object} data - json formatted response from a fetch + * @return {undefined} + */ + updateCountElement(counter, data) { + const currentCount = counter.innerText; + const count = data.count; + + if (count != currentCount) { + this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1); + counter.innerText = count; + } + } + + /** + * Toggle form. + * + * @param {Event} event + * @return {undefined} + */ + revealForm(event) { + let trigger = event.currentTarget; + let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0]; + + this.addRemoveClass(hidden, 'is-hidden', !hidden); + } + + /** + * Execute actions on targets based on triggers. + * + * @param {Event} event + * @return {undefined} + */ + toggleAction(event) { + let trigger = event.currentTarget; + let pressed = trigger.getAttribute('aria-pressed') === 'false'; + let targetId = trigger.dataset.controls; + + // Toggle pressed status on all triggers controlling the same target. + document.querySelectorAll('[data-controls="' + targetId + '"]') + .forEach(otherTrigger => otherTrigger.setAttribute( + 'aria-pressed', + otherTrigger.getAttribute('aria-pressed') === 'false' + )); + + // @todo Find a better way to handle the exception. + if (targetId && ! trigger.classList.contains('pulldown-menu')) { + let target = document.getElementById(targetId); + + this.addRemoveClass(target, 'is-hidden', !pressed); + this.addRemoveClass(target, 'is-active', pressed); + } + + // Show/hide pulldown-menus. + if (trigger.classList.contains('pulldown-menu')) { + this.toggleMenu(trigger, targetId); + } + + // Show/hide container. + let container = document.getElementById('hide-' + targetId); + + if (container) { + this.toggleContainer(container, pressed); + } + + // Check checkbox, if appropriate. + let checkbox = trigger.dataset.controlsCheckbox; + + if (checkbox) { + this.toggleCheckbox(checkbox, pressed); + } + + // Set focus, if appropriate. + let focus = trigger.dataset.focusTarget; + + if (focus) { + this.toggleFocus(focus); + } + } + + /** + * Show or hide menus. + * + * @param {Event} event + * @return {undefined} + */ + toggleMenu(trigger, targetId) { + let expanded = trigger.getAttribute('aria-expanded') == 'false'; + + trigger.setAttribute('aria-expanded', expanded); + + if (targetId) { + let target = document.getElementById(targetId); + + this.addRemoveClass(target, 'is-active', expanded); + } + } + + /** + * Show or hide generic containers. + * + * @param {object} container - DOM node + * @param {boolean} pressed - Is the trigger pressed? + * @return {undefined} + */ + toggleContainer(container, pressed) { + this.addRemoveClass(container, 'is-hidden', pressed); + } + + /** + * Check or uncheck a checbox. + * + * @param {object} checkbox - DOM node + * @param {boolean} pressed - Is the trigger pressed? + * @return {undefined} + */ + toggleCheckbox(checkbox, pressed) { + document.getElementById(checkbox).checked = !!pressed; + } + + /** + * Give the focus to an element. + * Only move the focus based on user interactions. + * + * @param {string} nodeId - ID of the DOM node to focus (button, link…) + * @return {undefined} + */ + toggleFocus(nodeId) { + let node = document.getElementById(nodeId); + + node.focus(); + + setTimeout(function() { + node.selectionStart = node.selectionEnd = 10000; + }, 0); + } + + /** + * Make a request and update the UI accordingly. + * This function is used for boosts, favourites, follows and unfollows. + * + * @param {Event} event + * @return {undefined} + */ + interact(event) { + event.preventDefault(); + + const bookwyrm = this; + const form = event.currentTarget; + const relatedforms = document.querySelectorAll(`.${form.dataset.id}`); + + // Toggle class on all related forms. + relatedforms.forEach(relatedForm => bookwyrm.addRemoveClass( + relatedForm, + 'is-hidden', + relatedForm.className.indexOf('is-hidden') == -1 + )); + + this.ajaxPost(form).catch(error => { + // @todo Display a notification in the UI instead. + console.warn('Request failed:', error); + }); + } + + /** + * Submit a form using POST. + * + * @param {object} form - Form to be submitted + * @return {Promise} + */ + ajaxPost(form) { + return fetch(form.action, { + method : "POST", + body: new FormData(form) + }); + } + + /** + * Add or remove a class based on a boolean condition. + * + * @param {object} node - DOM node to change class on + * @param {string} classname - Name of the class + * @param {boolean} add - Add? + * @return {undefined} + */ + addRemoveClass(node, classname, add) { + if (add) { + node.classList.add(classname); + } else { + node.classList.remove(classname); + } + } +} diff --git a/bookwyrm/static/js/check_all.js b/bookwyrm/static/js/check_all.js index 07d30a686..fd29f2cd6 100644 --- a/bookwyrm/static/js/check_all.js +++ b/bookwyrm/static/js/check_all.js @@ -1,17 +1,34 @@ -/* exported toggleAllCheckboxes */ -/** - * Toggle all descendant checkboxes of a target. - * - * Use `data-target="ID_OF_TARGET"` on the node being listened to. - * - * @param {Event} event - change Event - * @return {undefined} - */ -function toggleAllCheckboxes(event) { - const mainCheckbox = event.target; +(function() { + 'use strict'; + + /** + * Toggle all descendant checkboxes of a target. + * + * Use `data-target="ID_OF_TARGET"` on the node on which the event is listened + * to (checkbox, button, link…), where_ID_OF_TARGET_ should be the ID of an + * ancestor for the checkboxes. + * + * @example + * + * @param {Event} event + * @return {undefined} + */ + function toggleAllCheckboxes(event) { + const mainCheckbox = event.target; + + document + .querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`) + .forEach(checkbox => checkbox.checked = mainCheckbox.checked); + } document - .querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`) - .forEach(checkbox => {checkbox.checked = mainCheckbox.checked;}); -} + .querySelectorAll('[data-action="toggle-all"]') + .forEach(input => { + input.addEventListener('change', toggleAllCheckboxes); + }); +})(); diff --git a/bookwyrm/static/js/localstorage.js b/bookwyrm/static/js/localstorage.js index aa79ee303..059557799 100644 --- a/bookwyrm/static/js/localstorage.js +++ b/bookwyrm/static/js/localstorage.js @@ -1,20 +1,43 @@ -/* exported updateDisplay */ -/* globals addRemoveClass */ +/* exported LocalStorageTools */ +/* globals BookWyrm */ -// set javascript listeners -function updateDisplay(e) { - // used in set reading goal - var key = e.target.getAttribute('data-id'); - var value = e.target.getAttribute('data-value'); - window.localStorage.setItem(key, value); +let LocalStorageTools = new class { + constructor() { + document.querySelectorAll('[data-hide]') + .forEach(t => this.setDisplay(t)); - document.querySelectorAll('[data-hide="' + key + '"]') - .forEach(t => setDisplay(t)); -} - -function setDisplay(el) { - // used in set reading goal - var key = el.getAttribute('data-hide'); - var value = window.localStorage.getItem(key); - addRemoveClass(el, 'hidden', value); + document.querySelectorAll('.set-display') + .forEach(t => t.addEventListener('click', this.updateDisplay.bind(this))); + } + + /** + * Update localStorage, then display content based on keys in localStorage. + * + * @param {Event} event + * @return {undefined} + */ + updateDisplay(event) { + // used in set reading goal + let key = event.target.dataset.id; + let value = event.target.dataset.value; + + window.localStorage.setItem(key, value); + + document.querySelectorAll('[data-hide="' + key + '"]') + .forEach(node => this.setDisplay(node)); + } + + /** + * Toggle display of a DOM node based on its value in the localStorage. + * + * @param {object} node - DOM node to toggle. + * @return {undefined} + */ + setDisplay(node) { + // used in set reading goal + let key = node.dataset.hide; + let value = window.localStorage.getItem(key); + + BookWyrm.addRemoveClass(node, 'is-hidden', value); + } } diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js deleted file mode 100644 index 7a198619c..000000000 --- a/bookwyrm/static/js/shared.js +++ /dev/null @@ -1,169 +0,0 @@ -/* globals setDisplay TabGroup toggleAllCheckboxes updateDisplay */ - -// set up javascript listeners -window.onload = function() { - // buttons that display or hide content - document.querySelectorAll('[data-controls]') - .forEach(t => t.onclick = toggleAction); - - // javascript interactions (boost/fav) - Array.from(document.getElementsByClassName('interaction')) - .forEach(t => t.onsubmit = interact); - - // handle aria settings on menus - Array.from(document.getElementsByClassName('pulldown-menu')) - .forEach(t => t.onclick = toggleMenu); - - // hidden submit button in a form - document.querySelectorAll('.hidden-form input') - .forEach(t => t.onchange = revealForm); - - // polling - document.querySelectorAll('[data-poll]') - .forEach(el => polling(el)); - - // browser back behavior - document.querySelectorAll('[data-back]') - .forEach(t => t.onclick = back); - - Array.from(document.getElementsByClassName('tab-group')) - .forEach(t => new TabGroup(t)); - - // display based on localstorage vars - document.querySelectorAll('[data-hide]') - .forEach(t => setDisplay(t)); - - // update localstorage - Array.from(document.getElementsByClassName('set-display')) - .forEach(t => t.onclick = updateDisplay); - - // Toggle all checkboxes. - document - .querySelectorAll('[data-action="toggle-all"]') - .forEach(input => { - input.addEventListener('change', toggleAllCheckboxes); - }); -}; - -function back(e) { - e.preventDefault(); - history.back(); -} - -function polling(el, delay) { - delay = delay || 10000; - delay += (Math.random() * 1000); - setTimeout(function() { - fetch('/api/updates/' + el.getAttribute('data-poll')) - .then(response => response.json()) - .then(data => updateCountElement(el, data)); - polling(el, delay * 1.25); - }, delay, el); -} - -function updateCountElement(el, data) { - const currentCount = el.innerText; - const count = data.count; - if (count != currentCount) { - addRemoveClass(el.closest('[data-poll-wrapper]'), 'hidden', count < 1); - el.innerText = count; - } -} - - -function revealForm(e) { - var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0]; - if (hidden) { - removeClass(hidden, 'hidden'); - } -} - - -function toggleAction(e) { - var el = e.currentTarget; - var pressed = el.getAttribute('aria-pressed') == 'false'; - - var targetId = el.getAttribute('data-controls'); - document.querySelectorAll('[data-controls="' + targetId + '"]') - .forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false'))); - - if (targetId) { - var target = document.getElementById(targetId); - addRemoveClass(target, 'hidden', !pressed); - addRemoveClass(target, 'is-active', pressed); - } - - // show/hide container - var container = document.getElementById('hide-' + targetId); - if (container) { - addRemoveClass(container, 'hidden', pressed); - } - - // set checkbox, if appropriate - var checkbox = el.getAttribute('data-controls-checkbox'); - if (checkbox) { - document.getElementById(checkbox).checked = !!pressed; - } - - // set focus, if appropriate - var focus = el.getAttribute('data-focus-target'); - if (focus) { - var focusEl = document.getElementById(focus); - focusEl.focus(); - setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0); - } -} - -function interact(e) { - e.preventDefault(); - ajaxPost(e.target); - var identifier = e.target.getAttribute('data-id'); - Array.from(document.getElementsByClassName(identifier)) - .forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1)); -} - -function toggleMenu(e) { - var el = e.currentTarget; - var expanded = el.getAttribute('aria-expanded') == 'false'; - el.setAttribute('aria-expanded', expanded); - var targetId = el.getAttribute('data-controls'); - if (targetId) { - var target = document.getElementById(targetId); - addRemoveClass(target, 'is-active', expanded); - } -} - -function ajaxPost(form) { - fetch(form.action, { - method : "POST", - body: new FormData(form) - }); -} - -function addRemoveClass(el, classname, bool) { - if (bool) { - addClass(el, classname); - } else { - removeClass(el, classname); - } -} - -function addClass(el, classname) { - var classes = el.className.split(' '); - if (classes.indexOf(classname) > -1) { - return; - } - el.className = classes.concat(classname).join(' '); -} - -function removeClass(el, className) { - var classes = []; - if (el.className) { - classes = el.className.split(' '); - } - const idx = classes.indexOf(className); - if (idx > -1) { - classes.splice(idx, 1); - } - el.className = classes.join(' '); -} diff --git a/bookwyrm/static/js/tabs.js b/bookwyrm/static/js/vendor/tabs.js similarity index 100% rename from bookwyrm/static/js/tabs.js rename to bookwyrm/static/js/vendor/tabs.js diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index b91cebbac..f1aaaf51a 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -6,24 +6,36 @@ {% block title %}{{ book.title }}{% endblock %} {% block content %} -
+{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %} +

- {{ book.title }}{% if book.subtitle %}: - {{ book.subtitle }}{% endif %} + + {{ book.title }}{% if book.subtitle %}: + {{ book.subtitle }} + {% endif %} + + {% if book.series %} - ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})
+ + + + + ({{ book.series }} + {% if book.series_number %} #{{ book.series_number }}{% endif %}) + +
{% endif %}

{% if book.authors %}

- {% trans "by" %} {% include 'snippets/authors.html' with book=book %} + {% trans "by" %} {% include 'snippets/authors.html' with book=book %}

{% endif %}
- {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} + {% if user_authenticated and can_edit_book %} - {% if request.user.is_authenticated and not book.cover %} + {% if user_authenticated and not book.cover %}
{% trans "Add cover" as button_text %} {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %} @@ -55,31 +67,16 @@
{% endif %} -
-
- {% if book.isbn_13 %} -
-
{% trans "ISBN:" %}
-
{{ book.isbn_13 }}
+
+ {% with book=book %} +
+ {% include 'book/publisher_info.html' %}
- {% endif %} - {% if book.oclc_number %} -
-
{% trans "OCLC Number:" %}
-
{{ book.oclc_number }}
+
+ {% include 'book/book_identifiers.html' %}
- {% endif %} - - {% if book.asin %} -
-
{% trans "ASIN:" %}
-
{{ book.asin }}
-
- {% endif %} -
- - {% include 'book/publisher_info.html' with book=book %} + {% endwith %} {% if book.openlibrary_key %}

{% trans "View on OpenLibrary" %}

@@ -89,18 +86,35 @@
-

+

+ + {# @todo Is it possible to not hard-code the value? #} + + + {% include 'snippets/stars.html' with rating=rating %} - {% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %} + + {% blocktrans count counter=review_count trimmed %} + ({{ review_count }} review) + {% plural %} + ({{ review_count }} reviews) + {% endblocktrans %}

- {% include 'snippets/trimmed_text.html' with full=book|book_description %} + {% with full=book|book_description itemprop='abstract' %} + {% include 'snippets/trimmed_text.html' %} + {% endwith %} - {% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %} + {% if user_authenticated and can_edit_book and not book|book_description %} {% trans 'Add Description' as button_text %} {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %} - -
-
- {% for review in reviews %} -
- {% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %} -
- {% endfor %} - -
- {% for rating in ratings %} -
-
-
{% include 'snippets/avatar.html' with user=rating.user %}
-
- -
-

{% trans "rated it" %}

- {% include 'snippets/stars.html' with rating=rating.rating %} -
- -
+
+ {% for review in reviews %} +
+ {% with status=review hide_book=True depth=1 %} + {% include 'snippets/status/status.html' %} + {% endwith %}
-
{% endfor %} -
-
- {% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %} + +
+ {% for rating in ratings %} + {% with user=rating.user %} +
+
+
+ {% include 'snippets/avatar.html' %} +
+ +
+ +
+

{% trans "rated it" %}

+ + {% include 'snippets/stars.html' with rating=rating.rating %} +
+ +
+
+
+ {% endwith %} + {% endfor %} +
+
+ {% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %} +
- +{% endwith %} {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/bookwyrm/templates/book/book_identifiers.html b/bookwyrm/templates/book/book_identifiers.html new file mode 100644 index 000000000..d71ea4096 --- /dev/null +++ b/bookwyrm/templates/book/book_identifiers.html @@ -0,0 +1,27 @@ +{% spaceless %} + +{% load i18n %} + +
+ {% if book.isbn_13 %} +
+
{% trans "ISBN:" %}
+
{{ book.isbn_13 }}
+
+ {% endif %} + + {% if book.oclc_number %} +
+
{% trans "OCLC Number:" %}
+
{{ book.oclc_number }}
+
+ {% endif %} + + {% if book.asin %} +
+
{% trans "ASIN:" %}
+
{{ book.asin }}
+
+ {% endif %} +
+{% endspaceless %} diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit_book.html index 1da7c3f70..1702cf5d8 100644 --- a/bookwyrm/templates/book/edit_book.html +++ b/bookwyrm/templates/book/edit_book.html @@ -98,7 +98,7 @@

- +

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

{{ error | escape }}

@@ -109,7 +109,10 @@

{{ error | escape }}

{% endfor %} -

{{ form.series }}

+

+ + +

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

{{ error | escape }}

{% endfor %} diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions.html index 91259465e..70f067f76 100644 --- a/bookwyrm/templates/book/editions.html +++ b/bookwyrm/templates/book/editions.html @@ -25,7 +25,18 @@ {{ book.title }} - {% include 'book/publisher_info.html' with book=book %} + + {% with book=book %} +
+
+ {% include 'book/publisher_info.html' %} +
+ +
+ {% include 'book/book_identifiers.html' %} +
+
+ {% endwith %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %} diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html index 0ab354012..b7975a623 100644 --- a/bookwyrm/templates/book/publisher_info.html +++ b/bookwyrm/templates/book/publisher_info.html @@ -1,24 +1,70 @@ +{% spaceless %} + {% load i18n %} +{% load humanize %} +

- {% 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 %} + {% with format=book.physical_format pages=book.pages %} + {% if format %} + {% comment %} + @todo The bookFormat property is limited to a list of values whereas the book edition is free text. + @see https://schema.org/bookFormat + {% endcomment %} + + {% endif %} + + {% if pages %} + + {% endif %} + + {% if format and not pages %} + {% blocktrans %}{{ format }}{% endblocktrans %} + {% elif format and pages %} + {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %} + {% elif pages %} + {% blocktrans %}{{ pages }} pages{% endblocktrans %} + {% endif %} + {% endwith %}

+ {% if book.languages %} -

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

+ {% for language in book.languages %} + + {% endfor %} + +

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

{% 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 %} + {% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %} + {% if date or book.first_published_date %} + + {% endif %} + + {% comment %} + @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. + @see https://schema.org/Publisher + {% endcomment %} + {% if book.publishers %} + {% for publisher in book.publishers %} + + {% endfor %} + {% endif %} + + {% if date and publisher %} + {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} + {% elif date %} + {% blocktrans %}Published {{ date }}{% endblocktrans %} + {% elif publisher %} + {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %} + {% endif %} + {% endwith %}

+{% endspaceless %} diff --git a/bookwyrm/templates/components/dropdown.html b/bookwyrm/templates/components/dropdown.html index 72582ddc3..96dce8232 100644 --- a/bookwyrm/templates/components/dropdown.html +++ b/bookwyrm/templates/components/dropdown.html @@ -1,13 +1,34 @@ +{% spaceless %} {% load bookwyrm_tags %} + {% with 0|uuid as uuid %} - {% endwith %} +{% endspaceless %} diff --git a/bookwyrm/templates/components/inline_form.html b/bookwyrm/templates/components/inline_form.html index 40915a928..0b2c1300a 100644 --- a/bookwyrm/templates/components/inline_form.html +++ b/bookwyrm/templates/components/inline_form.html @@ -1,5 +1,5 @@ {% load i18n %} -
{% endblock %} diff --git a/bookwyrm/templates/lists/lists.html b/bookwyrm/templates/lists/lists.html index 27e56f11a..c7d789d0a 100644 --- a/bookwyrm/templates/lists/lists.html +++ b/bookwyrm/templates/lists/lists.html @@ -15,10 +15,12 @@ {% endif %}
+ {% if request.user.is_authenticated %}
{% trans "Create List" as button_text %} {% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text=button_text focus="create-list-header" %}
+ {% endif %}
diff --git a/bookwyrm/templates/moderation/report.html b/bookwyrm/templates/moderation/report.html index a231c41c9..a078fe450 100644 --- a/bookwyrm/templates/moderation/report.html +++ b/bookwyrm/templates/moderation/report.html @@ -1,5 +1,6 @@ {% extends 'settings/admin_layout.html' %} {% load i18n %} +{% load bookwyrm_tags %} {% load humanize %} {% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %} @@ -14,23 +15,9 @@ {% include 'moderation/report_preview.html' with report=report %}
-
-

{% trans "Actions" %}

-

{% trans "View user profile" %}

-
-

- {% trans "Send direct message" %} -

- - {% csrf_token %} - {% if report.user.is_active %} - - {% else %} - - {% endif %} - -
-
+{% include 'user_admin/user_info.html' with user=report.user %} + +{% include 'user_admin/user_moderation_actions.html' with user=report.user %}

{% trans "Moderator Comments" %}

diff --git a/bookwyrm/templates/moderation/report_modal.html b/bookwyrm/templates/moderation/report_modal.html index ce8408ee1..0d6504ab3 100644 --- a/bookwyrm/templates/moderation/report_modal.html +++ b/bookwyrm/templates/moderation/report_modal.html @@ -15,7 +15,9 @@ {% csrf_token %} +{% if status %} +{% endif %}

{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}

diff --git a/bookwyrm/templates/moderation/reports.html b/bookwyrm/templates/moderation/reports.html index f486711f4..f9d9d99b6 100644 --- a/bookwyrm/templates/moderation/reports.html +++ b/bookwyrm/templates/moderation/reports.html @@ -8,6 +8,7 @@ {% trans "Reports" %} {% endif %} {% endblock %} + {% block header %} {% if server %} {% blocktrans with server_name=server.server_name %}Reports: {{ server_name }}{% endblocktrans %} @@ -29,6 +30,8 @@
+{% include 'user_admin/user_admin_filters.html' %} +
{% if not reports %} {% trans "No reports found." %} diff --git a/bookwyrm/templates/notifications.html b/bookwyrm/templates/notifications.html index 7c694d78b..ba0a25cd0 100644 --- a/bookwyrm/templates/notifications.html +++ b/bookwyrm/templates/notifications.html @@ -123,7 +123,7 @@ {% include 'snippets/status_preview.html' with status=related_status %}
- {{ related_status.published_date | post_date }} + {{ related_status.published_date|timesince }} {% include 'snippets/privacy-icons.html' with item=related_status %}
diff --git a/bookwyrm/templates/search_results.html b/bookwyrm/templates/search_results.html index 6444cc184..4c9c23dae 100644 --- a/bookwyrm/templates/search_results.html +++ b/bookwyrm/templates/search_results.html @@ -37,7 +37,7 @@
{% endif %} -
+
{% for result_set in book_results|slice:"1:" %} {% if result_set.results %}
diff --git a/bookwyrm/templates/settings/admin_layout.html b/bookwyrm/templates/settings/admin_layout.html index 9340da9e1..4f71a2284 100644 --- a/bookwyrm/templates/settings/admin_layout.html +++ b/bookwyrm/templates/settings/admin_layout.html @@ -6,7 +6,14 @@ {% block content %}
-

{% block header %}{% endblock %}

+
+
+

{% block header %}{% endblock %}

+
+
+ {% block edit-button %}{% endblock %} +
+
diff --git a/bookwyrm/templates/settings/edit_server.html b/bookwyrm/templates/settings/edit_server.html new file mode 100644 index 000000000..c5702c848 --- /dev/null +++ b/bookwyrm/templates/settings/edit_server.html @@ -0,0 +1,71 @@ +{% extends 'settings/admin_layout.html' %} +{% load i18n %} +{% block title %}{% trans "Add server" %}{% endblock %} + +{% block header %} +{% trans "Add server" %} +{% trans "Back to server list" %} +{% endblock %} + +{% block panel %} + +
+ +
+ +
+ {% csrf_token %} +
+
+
+ + + {% for error in form.server_name.errors %} +

{{ error | escape }}

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

{{ error | escape }}

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

{{ error | escape }}

+ {% endfor %} +
+
+
+

+ + +

+ + +
+ +{% endblock %} diff --git a/bookwyrm/templates/settings/federated_server.html b/bookwyrm/templates/settings/federated_server.html index 13715bfb2..386433f33 100644 --- a/bookwyrm/templates/settings/federated_server.html +++ b/bookwyrm/templates/settings/federated_server.html @@ -1,67 +1,116 @@ {% extends 'settings/admin_layout.html' %} {% block title %}{{ server.server_name }}{% endblock %} {% load i18n %} +{% load bookwyrm_tags %} {% block header %} {{ server.server_name }} + +{% if server.status == "blocked" %}{% trans "Blocked" %} +{% endif %} + {% trans "Back to server list" %} {% endblock %} {% block panel %} +
+
+

{% trans "Details" %}

+
+
+
{% trans "Software:" %}
+
{{ server.application_type }}
+
+
+
{% trans "Version:" %}
+
{{ server.application_version }}
+
+
+
{% trans "Status:" %}
+
{{ server.status }}
+
+
+
+ +
+

{% trans "Activity" %}

+
+
+
{% trans "Users:" %}
+
+ {{ users.count }} + {% if server.user_set.count %}({% trans "View all" %}){% endif %} +
+
+
+
{% trans "Reports:" %}
+
+ {{ reports.count }} + {% if reports.count %}({% trans "View all" %}){% endif %} +
+
+
+
{% trans "Followed by us:" %}
+
+ {{ followed_by_us.count }} +
+
+
+
{% trans "Followed by them:" %}
+
+ {{ followed_by_them.count }} +
+
+
+
{% trans "Blocked by us:" %}
+
+ {{ blocked_by_us.count }} +
+
+
+
+
+
-

{% trans "Details" %}

-
-
-
{% trans "Software:" %}
-
{{ server.application_type }}
+
+
+

{% trans "Notes" %}

-
-
{% trans "Version:" %}
-
{{ server.application_version }}
+
+ {% trans "Edit" as button_text %} + {% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
-
-
{% trans "Status:" %}
-
Federated
-
-
+ + {% if server.notes %} +

{{ server.notes|to_markdown|safe }}

+ {% endif %} +
-

{% trans "Activity" %}

-
-
-
{% trans "Users:" %}
-
- {{ users.count }} - {% if server.user_set.count %}({% trans "View all" %}){% endif %} -
-
-
-
{% trans "Reports:" %}
-
- {{ reports.count }} - {% if reports.count %}({% trans "View all" %}){% endif %} -
-
-
-
{% trans "Followed by us:" %}
-
- {{ followed_by_us.count }} -
-
-
-
{% trans "Followed by them:" %}
-
- {{ followed_by_them.count }} -
-
-
-
{% trans "Blocked by us:" %}
-
- {{ blocked_by_us.count }} -
-
-
+

{% trans "Actions" %}

+ {% if server.status != 'blocked' %} +
+ {% csrf_token %} + +

{% trans "All users from this instance will be deactivated." %}

+
+ {% else %} +
+ {% csrf_token %} + +

{% trans "All users from this instance will be re-activated." %}

+
+ {% endif %}
{% endblock %} diff --git a/bookwyrm/templates/settings/federation.html b/bookwyrm/templates/settings/federation.html index 696d7a205..9eb0f8b12 100644 --- a/bookwyrm/templates/settings/federation.html +++ b/bookwyrm/templates/settings/federation.html @@ -4,8 +4,15 @@ {% block header %}{% trans "Federated Servers" %}{% endblock %} -{% block panel %} +{% block edit-button %} + + + {% trans "Add server" %} + + +{% endblock %} +{% block panel %} {% url 'settings-federation' as url %} diff --git a/bookwyrm/templates/settings/server_blocklist.html b/bookwyrm/templates/settings/server_blocklist.html new file mode 100644 index 000000000..0de49acd7 --- /dev/null +++ b/bookwyrm/templates/settings/server_blocklist.html @@ -0,0 +1,67 @@ +{% extends 'settings/admin_layout.html' %} +{% load i18n %} +{% block title %}{% trans "Add server" %}{% endblock %} + +{% block header %} +{% trans "Import Blocklist" %} +{% trans "Back to server list" %} +{% endblock %} + +{% block panel %} + +
+ +
+ +{% if succeeded and not failed %} +

{% trans "Success!" %}

+{% elif succeeded or failed %} +
+ {% if succeeded %} +

{% trans "Successfully blocked:" %} {{ succeeded }}

+ {% endif %} +

{% trans "Failed:" %}

+
    + {% for item in failed %} +
  • +
    +{{ item }}
    +
    +
  • + {% endfor %} +
+
+{% endif %} + + + {% csrf_token %} +
+ + + +
+ + + + +{% endblock %} diff --git a/bookwyrm/templates/snippets/authors.html b/bookwyrm/templates/snippets/authors.html index dd94b4714..9459b0fe4 100644 --- a/bookwyrm/templates/snippets/authors.html +++ b/bookwyrm/templates/snippets/authors.html @@ -1 +1,17 @@ -{% for author in book.authors.all %}{{ author.name }}{% if not forloop.last %}, {% endif %}{% endfor %} +{% spaceless %} +{% comment %} + @todo The author property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. + @see https://schema.org/Author +{% endcomment %} +{% for author in book.authors.all %} + {% if not forloop.last %}, {% endif %} +{% endfor %} +{% endspaceless %} diff --git a/bookwyrm/templates/snippets/book_cover.html b/bookwyrm/templates/snippets/book_cover.html index 0dbc3672d..ce47819e8 100644 --- a/bookwyrm/templates/snippets/book_cover.html +++ b/bookwyrm/templates/snippets/book_cover.html @@ -1,13 +1,29 @@ +{% spaceless %} + {% load bookwyrm_tags %} +{% load i18n %} +
-{% if book.cover %} -{{ book.alt_text }} -{% else %} -
- No cover -
-

{{ book.alt_text }}

+ {% if book.cover %} + {{ book.alt_text }} + {% else %} +
+ {% trans + +
+

{{ book.alt_text }}

+
-
-{% endif %} + {% endif %}
+{% endspaceless %} diff --git a/bookwyrm/templates/snippets/boost_button.html b/bookwyrm/templates/snippets/boost_button.html index 3bc1b6012..e590c58d8 100644 --- a/bookwyrm/templates/snippets/boost_button.html +++ b/bookwyrm/templates/snippets/boost_button.html @@ -2,7 +2,7 @@ {% load i18n %} {% with status.id|uuid as uuid %} -
+ {% csrf_token %} -
+ {% csrf_token %} -
+ {% csrf_token %} - + {% csrf_token %} {% if user.manually_approves_followers and request.user not in user.followers.all %} diff --git a/bookwyrm/templates/snippets/rate_action.html b/bookwyrm/templates/snippets/rate_action.html index 9fee692da..711c3b3e1 100644 --- a/bookwyrm/templates/snippets/rate_action.html +++ b/bookwyrm/templates/snippets/rate_action.html @@ -11,7 +11,7 @@ {% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' default_rating=book|user_rating:request.user %} - -
{% url 'settings-users' as url %} @@ -39,7 +41,7 @@ {% for user in users %} - + diff --git a/bookwyrm/templates/user_admin/user_admin_filters.html b/bookwyrm/templates/user_admin/user_admin_filters.html new file mode 100644 index 000000000..57e017e5f --- /dev/null +++ b/bookwyrm/templates/user_admin/user_admin_filters.html @@ -0,0 +1,6 @@ +{% extends 'snippets/filters_panel/filters_panel.html' %} + +{% block filter_fields %} +{% include 'user_admin/server_filter.html' %} +{% include 'user_admin/username_filter.html' %} +{% endblock %} diff --git a/bookwyrm/templates/user_admin/user_info.html b/bookwyrm/templates/user_admin/user_info.html new file mode 100644 index 000000000..e5f5d5806 --- /dev/null +++ b/bookwyrm/templates/user_admin/user_info.html @@ -0,0 +1,56 @@ +{% load i18n %} +{% load bookwyrm_tags %} +
+
+

{% trans "User details" %}

+
+ {% include 'user/user_preview.html' with user=user %} + {% if user.summary %} +
+ {{ user.summary | to_markdown | safe }} +
+ {% endif %} + +

{% trans "View user profile" %}

+
+
+ {% if not user.local %} + {% with server=user.federated_server %} +
+

{% trans "Instance details" %}

+
+ {% if server %} +
{{ server.server_name }}
+
+
+
{% trans "Software:" %}
+
{{ server.application_type }}
+
+
+
{% trans "Version:" %}
+
{{ server.application_version }}
+
+
+
{% trans "Status:" %}
+
{{ server.status }}
+
+
+ {% if server.notes %} +
{% trans "Notes" %}
+
+ {{ server.notes }} +
+ {% endif %} + +

+ {% trans "View instance" %} +

+ {% else %} + {% trans "Not set" %} + {% endif %} +
+
+ {% endwith %} + {% endif %} +
+ diff --git a/bookwyrm/templates/user_admin/user_moderation_actions.html b/bookwyrm/templates/user_admin/user_moderation_actions.html new file mode 100644 index 000000000..816e787a2 --- /dev/null +++ b/bookwyrm/templates/user_admin/user_moderation_actions.html @@ -0,0 +1,42 @@ +{% load i18n %} +
+

{% trans "Actions" %}

+
+

+ {% trans "Send direct message" %} +

+ + {% csrf_token %} + {% if user.is_active %} + + {% else %} + + {% endif %} + +
+ {% if user.local %} +
+
+ {% csrf_token %} + + {% if group_form.non_field_errors %} + {{ group_form.non_field_errors }} + {% endif %} + {% with group=user.groups.first %} +
+ +
+ {% for error in group_form.groups.errors %} +

{{ error | escape }}

+ {% endfor %} + {% endwith %} + + +
+ {% endif %} +
diff --git a/bookwyrm/templates/user_admin/username_filter.html b/bookwyrm/templates/user_admin/username_filter.html new file mode 100644 index 000000000..d7da033a9 --- /dev/null +++ b/bookwyrm/templates/user_admin/username_filter.html @@ -0,0 +1,8 @@ +{% extends 'snippets/filters_panel/filter_field.html' %} +{% load i18n %} + +{% block filter %} + + +{% endblock %} + diff --git a/bookwyrm/templatetags/bookwyrm_tags.py b/bookwyrm/templatetags/bookwyrm_tags.py index 775c61903..649a0dfa1 100644 --- a/bookwyrm/templatetags/bookwyrm_tags.py +++ b/bookwyrm/templatetags/bookwyrm_tags.py @@ -1,11 +1,8 @@ """ template filters """ from uuid import uuid4 -from datetime import datetime -from dateutil.relativedelta import relativedelta -from django import template +from django import template, utils from django.db.models import Avg -from django.utils import timezone from bookwyrm import models, views from bookwyrm.views.status import to_markdown @@ -62,14 +59,10 @@ def get_notification_count(user): def get_replies(status): """ get all direct replies to a status """ # TODO: this limit could cause problems - return ( - models.Status.objects.filter( - reply_parent=status, - deleted=False, - ) - .select_subclasses() - .all()[:10] - ) + return models.Status.objects.filter( + reply_parent=status, + deleted=False, + ).select_subclasses()[:10] @register.filter(name="parent") @@ -133,28 +126,6 @@ def get_uuid(identifier): return "%s%s" % (identifier, uuid4()) -@register.filter(name="post_date") -def time_since(date): - """ concise time ago function """ - if not isinstance(date, datetime): - return "" - now = timezone.now() - - if date < (now - relativedelta(weeks=1)): - formatter = "%b %-d" - if date.year != now.year: - formatter += " %Y" - return date.strftime(formatter) - delta = relativedelta(now, date) - if delta.days: - return "%dd" % delta.days - if delta.hours: - return "%dh" % delta.hours - if delta.minutes: - return "%dm" % delta.minutes - return "%ds" % delta.seconds - - @register.filter(name="to_markdown") def get_markdown(content): """ convert markdown to html """ @@ -246,3 +217,10 @@ def active_read_through(book, user): def comparison_bool(str1, str2): """ idk why I need to write a tag for this, it reutrns a bool """ return str1 == str2 + + +@register.simple_tag(takes_context=False) +def get_lang(): + """ get current language, strip to the first two letters """ + language = utils.translation.get_language() + return language[0 : language.find("-")] diff --git a/bookwyrm/tests/data/ap_user_rat.json b/bookwyrm/tests/data/ap_user_rat.json new file mode 100644 index 000000000..0e36f1c62 --- /dev/null +++ b/bookwyrm/tests/data/ap_user_rat.json @@ -0,0 +1,39 @@ +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + "manuallyApprovesFollowers": "as:manuallyApprovesFollowers", + "schema": "http://schema.org#", + "PropertyValue": "schema:PropertyValue", + "value": "schema:value" + } + ], + "id": "https://example.com/users/rat", + "type": "Person", + "preferredUsername": "rat", + "name": "RAT???", + "inbox": "https://example.com/users/rat/inbox", + "outbox": "https://example.com/users/rat/outbox", + "followers": "https://example.com/users/rat/followers", + "following": "https://example.com/users/rat/following", + "summary": "", + "publicKey": { + "id": "https://example.com/users/rat/#main-key", + "owner": "https://example.com/users/rat", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC6QisDrjOQvkRo/MqNmSYPwqtt\nCxg/8rCW+9jKbFUKvqjTeKVotEE85122v/DCvobCCdfQuYIFdVMk+dB1xJ0iPGPg\nyU79QHY22NdV9mFKA2qtXVVxb5cxpA4PlwOHM6PM/k8B+H09OUrop2aPUAYwy+vg\n+MXyz8bAXrIS1kq6fQIDAQAB\n-----END PUBLIC KEY-----" + }, + "endpoints": { + "sharedInbox": "https://example.com/inbox" + }, + "bookwyrmUser": true, + "manuallyApprovesFollowers": false, + "discoverable": true, + "devices": "https://friend.camp/users/tripofmice/collections/devices", + "tag": [], + "icon": { + "type": "Image", + "mediaType": "image/png", + "url": "https://example.com/images/avatars/AL-2-crop-50.png" + } +} diff --git a/bookwyrm/tests/data/bw_edition.json b/bookwyrm/tests/data/bw_edition.json index 0cc17d29a..6194e4090 100644 --- a/bookwyrm/tests/data/bw_edition.json +++ b/bookwyrm/tests/data/bw_edition.json @@ -1,5 +1,6 @@ { "id": "https://bookwyrm.social/book/5989", + "lastEditedBy": "https://example.com/users/rat", "type": "Edition", "authors": [ "https://bookwyrm.social/author/417" diff --git a/bookwyrm/tests/management/__init__.py b/bookwyrm/tests/management/__init__.py new file mode 100644 index 000000000..b6e690fd5 --- /dev/null +++ b/bookwyrm/tests/management/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/bookwyrm/tests/management/test_populate_streams.py b/bookwyrm/tests/management/test_populate_streams.py new file mode 100644 index 000000000..6a9b6b8ac --- /dev/null +++ b/bookwyrm/tests/management/test_populate_streams.py @@ -0,0 +1,44 @@ +""" test populating user streams """ +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models +from bookwyrm.management.commands.populate_streams import populate_streams + + +@patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") +class Activitystreams(TestCase): + """ using redis to build activity streams """ + + def setUp(self): + """ we need some stuff """ + self.local_user = models.User.objects.create_user( + "mouse", "mouse@mouse.mouse", "password", local=True, localname="mouse" + ) + self.another_user = models.User.objects.create_user( + "nutria", "nutria@nutria.nutria", "password", local=True, localname="nutria" + ) + with patch("bookwyrm.models.user.set_remote_server.delay"): + self.remote_user = models.User.objects.create_user( + "rat", + "rat@rat.com", + "ratword", + local=False, + remote_id="https://example.com/users/rat", + inbox="https://example.com/users/rat/inbox", + outbox="https://example.com/users/rat/outbox", + ) + self.book = models.Edition.objects.create(title="test book") + + def test_populate_streams(self, _): + """ make sure the function on the redis manager gets called """ + with patch("bookwyrm.activitystreams.ActivityStream.add_status"): + models.Comment.objects.create( + user=self.local_user, content="hi", book=self.book + ) + + with patch( + "bookwyrm.activitystreams.ActivityStream.populate_store" + ) as redis_mock: + populate_streams() + self.assertEqual(redis_mock.call_count, 6) # 2 users x 3 streams diff --git a/bookwyrm/tests/models/test_activitypub_mixin.py b/bookwyrm/tests/models/test_activitypub_mixin.py index 0d1acd978..e172ede96 100644 --- a/bookwyrm/tests/models/test_activitypub_mixin.py +++ b/bookwyrm/tests/models/test_activitypub_mixin.py @@ -155,8 +155,8 @@ class ActivitypubMixins(TestCase): recipients = ActivitypubMixin.get_recipients(mock_self) self.assertEqual(len(recipients), 2) - self.assertEqual(recipients[0], another_remote_user.inbox) - self.assertEqual(recipients[1], self.remote_user.inbox) + self.assertTrue(another_remote_user.inbox in recipients) + self.assertTrue(self.remote_user.inbox in recipients) def test_get_recipients_direct(self, _): """ determines the recipients for a user's object broadcast """ diff --git a/bookwyrm/tests/models/test_base_model.py b/bookwyrm/tests/models/test_base_model.py index 25a2e7ee6..442f98ca1 100644 --- a/bookwyrm/tests/models/test_base_model.py +++ b/bookwyrm/tests/models/test_base_model.py @@ -1,4 +1,5 @@ """ testing models """ +from unittest.mock import patch from django.test import TestCase from bookwyrm import models @@ -9,6 +10,22 @@ from bookwyrm.settings import DOMAIN class BaseModel(TestCase): """ functionality shared across models """ + def setUp(self): + """ shared data """ + self.local_user = models.User.objects.create_user( + "mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse" + ) + with patch("bookwyrm.models.user.set_remote_server.delay"): + self.remote_user = models.User.objects.create_user( + "rat", + "rat@rat.com", + "ratword", + local=False, + remote_id="https://example.com/users/rat", + inbox="https://example.com/users/rat/inbox", + outbox="https://example.com/users/rat/outbox", + ) + def test_remote_id(self): """ these should be generated """ instance = base_model.BookWyrmModel() @@ -18,11 +35,8 @@ class BaseModel(TestCase): def test_remote_id_with_user(self): """ format of remote id when there's a user object """ - user = models.User.objects.create_user( - "mouse", "mouse@mouse.com", "mouseword", local=True, localname="mouse" - ) instance = base_model.BookWyrmModel() - instance.user = user + instance.user = self.local_user instance.id = 1 expected = instance.get_remote_id() self.assertEqual(expected, "https://%s/user/mouse/bookwyrmmodel/1" % DOMAIN) @@ -42,3 +56,66 @@ class BaseModel(TestCase): instance.remote_id = None base_model.set_remote_id(None, instance, False) self.assertIsNone(instance.remote_id) + + @patch("bookwyrm.activitystreams.ActivityStream.add_status") + def test_object_visible_to_user(self, _): + """ does a user have permission to view an object """ + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="public" + ) + self.assertTrue(obj.visible_to_user(self.local_user)) + + obj = models.Shelf.objects.create( + name="test", user=self.remote_user, privacy="unlisted" + ) + self.assertTrue(obj.visible_to_user(self.local_user)) + + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="followers" + ) + self.assertFalse(obj.visible_to_user(self.local_user)) + + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="direct" + ) + self.assertFalse(obj.visible_to_user(self.local_user)) + + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="direct" + ) + obj.mention_users.add(self.local_user) + self.assertTrue(obj.visible_to_user(self.local_user)) + + @patch("bookwyrm.activitystreams.ActivityStream.add_status") + def test_object_visible_to_user_follower(self, _): + """ what you can see if you follow a user """ + self.remote_user.followers.add(self.local_user) + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="followers" + ) + self.assertTrue(obj.visible_to_user(self.local_user)) + + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="direct" + ) + self.assertFalse(obj.visible_to_user(self.local_user)) + + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="direct" + ) + obj.mention_users.add(self.local_user) + self.assertTrue(obj.visible_to_user(self.local_user)) + + @patch("bookwyrm.activitystreams.ActivityStream.add_status") + def test_object_visible_to_user_blocked(self, _): + """ you can't see it if they block you """ + self.remote_user.blocks.add(self.local_user) + obj = models.Status.objects.create( + content="hi", user=self.remote_user, privacy="public" + ) + self.assertFalse(obj.visible_to_user(self.local_user)) + + obj = models.Shelf.objects.create( + name="test", user=self.remote_user, privacy="unlisted" + ) + self.assertFalse(obj.visible_to_user(self.local_user)) diff --git a/bookwyrm/tests/models/test_federated_server.py b/bookwyrm/tests/models/test_federated_server.py new file mode 100644 index 000000000..4e9e8b686 --- /dev/null +++ b/bookwyrm/tests/models/test_federated_server.py @@ -0,0 +1,67 @@ +""" testing models """ +from unittest.mock import patch +from django.test import TestCase + +from bookwyrm import models + + +class FederatedServer(TestCase): + """ federate server management """ + + def setUp(self): + """ we'll need a user """ + self.server = models.FederatedServer.objects.create(server_name="test.server") + with patch("bookwyrm.models.user.set_remote_server.delay"): + self.remote_user = models.User.objects.create_user( + "rat", + "rat@rat.com", + "ratword", + federated_server=self.server, + local=False, + remote_id="https://example.com/users/rat", + inbox="https://example.com/users/rat/inbox", + outbox="https://example.com/users/rat/outbox", + ) + self.inactive_remote_user = models.User.objects.create_user( + "nutria", + "nutria@nutria.com", + "nutriaword", + federated_server=self.server, + local=False, + remote_id="https://example.com/users/nutria", + inbox="https://example.com/users/nutria/inbox", + outbox="https://example.com/users/nutria/outbox", + is_active=False, + deactivation_reason="self_deletion", + ) + + def test_block_unblock(self): + """ block a server and all users on it """ + self.assertEqual(self.server.status, "federated") + self.assertTrue(self.remote_user.is_active) + self.assertFalse(self.inactive_remote_user.is_active) + + self.server.block() + + self.assertEqual(self.server.status, "blocked") + self.remote_user.refresh_from_db() + self.assertFalse(self.remote_user.is_active) + self.assertEqual(self.remote_user.deactivation_reason, "domain_block") + + self.inactive_remote_user.refresh_from_db() + self.assertFalse(self.inactive_remote_user.is_active) + self.assertEqual(self.inactive_remote_user.deactivation_reason, "self_deletion") + + # UNBLOCK + self.server.unblock() + + self.assertEqual(self.server.status, "federated") + # user blocked in deactivation is reactivated + self.remote_user.refresh_from_db() + self.assertTrue(self.remote_user.is_active) + self.assertIsNone(self.remote_user.deactivation_reason) + + # deleted user remains deleted + self.inactive_remote_user.refresh_from_db() + self.assertFalse(self.inactive_remote_user.is_active) + self.assertEqual(self.inactive_remote_user.deactivation_reason, "self_deletion") diff --git a/bookwyrm/tests/models/test_list.py b/bookwyrm/tests/models/test_list.py index 48f918a04..4e3460a91 100644 --- a/bookwyrm/tests/models/test_list.py +++ b/bookwyrm/tests/models/test_list.py @@ -51,11 +51,12 @@ class List(TestCase): book_list=book_list, book=self.book, user=self.local_user, + order=1, ) self.assertTrue(item.approved) self.assertEqual(item.privacy, "unlisted") - self.assertEqual(item.recipients, [self.local_user]) + self.assertEqual(item.recipients, []) def test_list_item_pending(self, _): """ a list entry """ @@ -65,10 +66,14 @@ class List(TestCase): ) item = models.ListItem.objects.create( - book_list=book_list, book=self.book, user=self.local_user, approved=False + book_list=book_list, + book=self.book, + user=self.local_user, + approved=False, + order=1, ) self.assertFalse(item.approved) self.assertEqual(item.book_list.privacy, "public") self.assertEqual(item.privacy, "direct") - self.assertEqual(item.recipients, [self.local_user]) + self.assertEqual(item.recipients, []) diff --git a/bookwyrm/tests/models/test_shelf_model.py b/bookwyrm/tests/models/test_shelf_model.py index ebda04999..45ae1fa13 100644 --- a/bookwyrm/tests/models/test_shelf_model.py +++ b/bookwyrm/tests/models/test_shelf_model.py @@ -1,4 +1,6 @@ """ testing models """ +import json +from unittest.mock import patch from django.test import TestCase from bookwyrm import models, settings @@ -18,30 +20,19 @@ class Shelf(TestCase): def test_remote_id(self): """ shelves use custom remote ids """ - real_broadcast = models.Shelf.broadcast - - def broadcast_mock(_, activity, user, **kwargs): - """ nah """ - - models.Shelf.broadcast = broadcast_mock - shelf = models.Shelf.objects.create( - name="Test Shelf", identifier="test-shelf", user=self.local_user - ) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + shelf = models.Shelf.objects.create( + name="Test Shelf", identifier="test-shelf", user=self.local_user + ) expected_id = "https://%s/user/mouse/books/test-shelf" % settings.DOMAIN self.assertEqual(shelf.get_remote_id(), expected_id) - models.Shelf.broadcast = real_broadcast def test_to_activity(self): """ jsonify it """ - real_broadcast = models.Shelf.broadcast - - def empty_mock(_, activity, user, **kwargs): - """ nah """ - - models.Shelf.broadcast = empty_mock - shelf = models.Shelf.objects.create( - name="Test Shelf", identifier="test-shelf", user=self.local_user - ) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + shelf = models.Shelf.objects.create( + name="Test Shelf", identifier="test-shelf", user=self.local_user + ) activity_json = shelf.to_activity() self.assertIsInstance(activity_json, dict) self.assertEqual(activity_json["id"], shelf.remote_id) @@ -49,77 +40,53 @@ class Shelf(TestCase): self.assertEqual(activity_json["type"], "Shelf") self.assertEqual(activity_json["name"], "Test Shelf") self.assertEqual(activity_json["owner"], self.local_user.remote_id) - models.Shelf.broadcast = real_broadcast def test_create_update_shelf(self): """ create and broadcast shelf creation """ - real_broadcast = models.Shelf.broadcast - def create_mock(_, activity, user, **kwargs): - """ ok """ - self.assertEqual(user.remote_id, self.local_user.remote_id) - self.assertEqual(activity["type"], "Create") - self.assertEqual(activity["actor"], self.local_user.remote_id) - self.assertEqual(activity["object"]["name"], "Test Shelf") - - models.Shelf.broadcast = create_mock - - shelf = models.Shelf.objects.create( - name="Test Shelf", identifier="test-shelf", user=self.local_user - ) - - def update_mock(_, activity, user, **kwargs): - """ ok """ - self.assertEqual(user.remote_id, self.local_user.remote_id) - self.assertEqual(activity["type"], "Update") - self.assertEqual(activity["actor"], self.local_user.remote_id) - self.assertEqual(activity["object"]["name"], "arthur russel") - - models.Shelf.broadcast = update_mock + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: + shelf = models.Shelf.objects.create( + name="Test Shelf", identifier="test-shelf", user=self.local_user + ) + activity = json.loads(mock.call_args[0][1]) + self.assertEqual(activity["type"], "Create") + self.assertEqual(activity["actor"], self.local_user.remote_id) + self.assertEqual(activity["object"]["name"], "Test Shelf") shelf.name = "arthur russel" - shelf.save() + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: + shelf.save() + activity = json.loads(mock.call_args[0][1]) + self.assertEqual(activity["type"], "Update") + self.assertEqual(activity["actor"], self.local_user.remote_id) + self.assertEqual(activity["object"]["name"], "arthur russel") self.assertEqual(shelf.name, "arthur russel") - models.Shelf.broadcast = real_broadcast def test_shelve(self): """ create and broadcast shelf creation """ - real_broadcast = models.Shelf.broadcast - real_shelfbook_broadcast = models.ShelfBook.broadcast + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + shelf = models.Shelf.objects.create( + name="Test Shelf", identifier="test-shelf", user=self.local_user + ) - def add_mock(_, activity, user, **kwargs): - """ ok """ - self.assertEqual(user.remote_id, self.local_user.remote_id) - self.assertEqual(activity["type"], "Add") - self.assertEqual(activity["actor"], self.local_user.remote_id) - self.assertEqual(activity["object"]["id"], self.book.remote_id) - self.assertEqual(activity["target"], shelf.remote_id) - - def remove_mock(_, activity, user, **kwargs): - """ ok """ - self.assertEqual(user.remote_id, self.local_user.remote_id) - self.assertEqual(activity["type"], "Remove") - self.assertEqual(activity["actor"], self.local_user.remote_id) - self.assertEqual(activity["object"]["id"], self.book.remote_id) - self.assertEqual(activity["target"], shelf.remote_id) - - def empty_mock(_, activity, user, **kwargs): - """ nah """ - - models.Shelf.broadcast = empty_mock - shelf = models.Shelf.objects.create( - name="Test Shelf", identifier="test-shelf", user=self.local_user - ) - - models.ShelfBook.broadcast = add_mock - shelf_book = models.ShelfBook.objects.create( - shelf=shelf, user=self.local_user, book=self.book - ) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: + shelf_book = models.ShelfBook.objects.create( + shelf=shelf, user=self.local_user, book=self.book + ) + self.assertEqual(mock.call_count, 1) + activity = json.loads(mock.call_args[0][1]) + self.assertEqual(activity["type"], "Add") + self.assertEqual(activity["actor"], self.local_user.remote_id) + self.assertEqual(activity["object"]["id"], shelf_book.remote_id) + self.assertEqual(activity["target"], shelf.remote_id) self.assertEqual(shelf.books.first(), self.book) - models.ShelfBook.broadcast = remove_mock - shelf_book.delete() + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: + shelf_book.delete() + self.assertEqual(mock.call_count, 1) + activity = json.loads(mock.call_args[0][1]) + self.assertEqual(activity["type"], "Remove") + self.assertEqual(activity["actor"], self.local_user.remote_id) + self.assertEqual(activity["object"]["id"], shelf_book.remote_id) + self.assertEqual(activity["target"], shelf.remote_id) self.assertFalse(shelf.books.exists()) - - models.ShelfBook.broadcast = real_shelfbook_broadcast - models.Shelf.broadcast = real_broadcast diff --git a/bookwyrm/tests/models/test_user_model.py b/bookwyrm/tests/models/test_user_model.py index bd5255cef..883ef669e 100644 --- a/bookwyrm/tests/models/test_user_model.py +++ b/bookwyrm/tests/models/test_user_model.py @@ -1,4 +1,5 @@ """ testing models """ +import json from unittest.mock import patch from django.test import TestCase import responses @@ -152,3 +153,17 @@ class User(TestCase): self.assertEqual(server.server_name, DOMAIN) self.assertIsNone(server.application_type) self.assertIsNone(server.application_version) + + def test_delete_user(self): + """ deactivate a user """ + self.assertTrue(self.user.is_active) + with patch( + "bookwyrm.models.activitypub_mixin.broadcast_task.delay" + ) as broadcast_mock: + self.user.delete() + + self.assertEqual(broadcast_mock.call_count, 1) + activity = json.loads(broadcast_mock.call_args[0][1]) + self.assertEqual(activity["type"], "Delete") + self.assertEqual(activity["object"], self.user.remote_id) + self.assertFalse(self.user.is_active) diff --git a/bookwyrm/tests/test_templatetags.py b/bookwyrm/tests/test_templatetags.py index b4dc517f1..2fadb978b 100644 --- a/bookwyrm/tests/test_templatetags.py +++ b/bookwyrm/tests/test_templatetags.py @@ -181,36 +181,6 @@ class TemplateTags(TestCase): uuid = bookwyrm_tags.get_uuid("hi") self.assertTrue(re.match(r"hi[A-Za-z0-9\-]", uuid)) - def test_time_since(self, _): - """ ultraconcise timestamps """ - self.assertEqual(bookwyrm_tags.time_since("bleh"), "") - - now = timezone.now() - self.assertEqual(bookwyrm_tags.time_since(now), "0s") - - seconds_ago = now - relativedelta(seconds=4) - self.assertEqual(bookwyrm_tags.time_since(seconds_ago), "4s") - - minutes_ago = now - relativedelta(minutes=8) - self.assertEqual(bookwyrm_tags.time_since(minutes_ago), "8m") - - hours_ago = now - relativedelta(hours=9) - self.assertEqual(bookwyrm_tags.time_since(hours_ago), "9h") - - days_ago = now - relativedelta(days=3) - self.assertEqual(bookwyrm_tags.time_since(days_ago), "3d") - - # I am not going to figure out how to mock dates tonight. - months_ago = now - relativedelta(months=5) - self.assertTrue( - re.match(r"[A-Z][a-z]{2} \d?\d", bookwyrm_tags.time_since(months_ago)) - ) - - years_ago = now - relativedelta(years=10) - self.assertTrue( - re.match(r"[A-Z][a-z]{2} \d?\d \d{4}", bookwyrm_tags.time_since(years_ago)) - ) - def test_get_markdown(self, _): """ mardown format data """ result = bookwyrm_tags.get_markdown("_hi_") diff --git a/bookwyrm/tests/views/inbox/test_inbox.py b/bookwyrm/tests/views/inbox/test_inbox.py index 12d7a736c..c39a3fd2d 100644 --- a/bookwyrm/tests/views/inbox/test_inbox.py +++ b/bookwyrm/tests/views/inbox/test_inbox.py @@ -1,11 +1,13 @@ """ tests incoming activities""" import json +import pathlib from unittest.mock import patch from django.http import HttpResponseNotAllowed, HttpResponseNotFound from django.test import TestCase, Client +from django.test.client import RequestFactory -from bookwyrm import models +from bookwyrm import models, views # pylint: disable=too-many-public-methods @@ -15,6 +17,7 @@ class Inbox(TestCase): def setUp(self): """ basic user and book data """ self.client = Client() + self.factory = RequestFactory() local_user = models.User.objects.create_user( "mouse@example.com", "mouse@mouse.com", @@ -24,6 +27,16 @@ class Inbox(TestCase): ) local_user.remote_id = "https://example.com/user/mouse" local_user.save(broadcast=False) + with patch("bookwyrm.models.user.set_remote_server.delay"): + self.remote_user = models.User.objects.create_user( + "rat", + "rat@rat.com", + "ratword", + local=False, + remote_id="https://example.com/users/rat", + inbox="https://example.com/users/rat/inbox", + outbox="https://example.com/users/rat/outbox", + ) self.create_json = { "id": "hi", "type": "Create", @@ -106,3 +119,44 @@ class Inbox(TestCase): "/inbox", json.dumps(activity), content_type="application/json" ) self.assertEqual(result.status_code, 200) + + def test_is_blocked_user_agent(self): + """ check for blocked servers """ + request = self.factory.post( + "", + HTTP_USER_AGENT="http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)", + ) + self.assertFalse(views.inbox.is_blocked_user_agent(request)) + + models.FederatedServer.objects.create( + server_name="mastodon.social", status="blocked" + ) + self.assertTrue(views.inbox.is_blocked_user_agent(request)) + + def test_is_blocked_activity(self): + """ check for blocked servers """ + activity = {"actor": "https://mastodon.social/user/whaatever/else"} + self.assertFalse(views.inbox.is_blocked_activity(activity)) + + models.FederatedServer.objects.create( + server_name="mastodon.social", status="blocked" + ) + self.assertTrue(views.inbox.is_blocked_activity(activity)) + + def test_create_by_deactivated_user(self): + """ don't let deactivated users post """ + self.remote_user.delete(broadcast=False) + self.assertTrue(self.remote_user.deleted) + datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_note.json") + status_data = json.loads(datafile.read_bytes()) + activity = self.create_json + activity["actor"] = self.remote_user.remote_id + activity["object"] = status_data + + with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid: + mock_valid.return_value = True + + result = self.client.post( + "/inbox", json.dumps(activity), content_type="application/json" + ) + self.assertEqual(result.status_code, 403) diff --git a/bookwyrm/tests/views/inbox/test_inbox_add.py b/bookwyrm/tests/views/inbox/test_inbox_add.py index b2b653381..a5c629a8d 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_add.py +++ b/bookwyrm/tests/views/inbox/test_inbox_add.py @@ -94,6 +94,7 @@ class InboxAdd(TestCase): "type": "ListItem", "book": self.book.remote_id, "id": "https://bookwyrm.social/listbook/6189", + "order": 1, }, "target": "https://bookwyrm.social/user/mouse/list/to-read", "@context": "https://www.w3.org/ns/activitystreams", diff --git a/bookwyrm/tests/views/inbox/test_inbox_announce.py b/bookwyrm/tests/views/inbox/test_inbox_announce.py index a730045a4..954d4e647 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_announce.py +++ b/bookwyrm/tests/views/inbox/test_inbox_announce.py @@ -136,6 +136,9 @@ class InboxActivities(TestCase): "id": "http://www.faraway.com/boost/12", "actor": self.remote_user.remote_id, "object": status.remote_id, + "to": ["https://www.w3.org/ns/activitystreams#public"], + "cc": ["https://example.com/user/mouse/followers"], + "published": "Mon, 25 May 2020 19:31:20 GMT", } responses.add( responses.GET, status.remote_id, json=status.to_activity(), status=200 @@ -185,6 +188,7 @@ class InboxActivities(TestCase): "id": "http://fake.com/unknown/boost", "actor": self.remote_user.remote_id, "object": self.status.remote_id, + "published": "Mon, 25 May 2020 19:31:20 GMT", }, } views.inbox.activity_task(activity) diff --git a/bookwyrm/tests/views/inbox/test_inbox_create.py b/bookwyrm/tests/views/inbox/test_inbox_create.py index f8ed6a84a..3d2ce1c09 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_create.py +++ b/bookwyrm/tests/views/inbox/test_inbox_create.py @@ -6,6 +6,7 @@ from unittest.mock import patch from django.test import TestCase from bookwyrm import models, views +from bookwyrm.activitypub import ActivitySerializerError # pylint: disable=too-many-public-methods @@ -31,7 +32,7 @@ class InboxCreate(TestCase): remote_id="https://example.com/status/1", ) with patch("bookwyrm.models.user.set_remote_server.delay"): - models.User.objects.create_user( + self.remote_user = models.User.objects.create_user( "rat", "rat@rat.com", "ratword", @@ -51,7 +52,7 @@ class InboxCreate(TestCase): } models.SiteSettings.objects.create() - def test_handle_create_status(self): + def test_create_status(self): """ the "it justs works" mode """ self.assertEqual(models.Status.objects.count(), 1) @@ -82,7 +83,7 @@ class InboxCreate(TestCase): views.inbox.activity_task(activity) self.assertEqual(models.Status.objects.count(), 2) - def test_handle_create_status_remote_note_with_mention(self): + def test_create_status_remote_note_with_mention(self): """ should only create it under the right circumstances """ self.assertEqual(models.Status.objects.count(), 1) self.assertFalse( @@ -105,7 +106,7 @@ class InboxCreate(TestCase): ) self.assertEqual(models.Notification.objects.get().notification_type, "MENTION") - def test_handle_create_status_remote_note_with_reply(self): + def test_create_status_remote_note_with_reply(self): """ should only create it under the right circumstances """ self.assertEqual(models.Status.objects.count(), 1) self.assertFalse(models.Notification.objects.filter(user=self.local_user)) @@ -126,7 +127,7 @@ class InboxCreate(TestCase): self.assertTrue(models.Notification.objects.filter(user=self.local_user)) self.assertEqual(models.Notification.objects.get().notification_type, "REPLY") - def test_handle_create_list(self): + def test_create_list(self): """ a new list """ activity = self.create_json activity["object"] = { @@ -149,3 +150,23 @@ class InboxCreate(TestCase): self.assertEqual(book_list.curation, "curated") self.assertEqual(book_list.description, "summary text") self.assertEqual(book_list.remote_id, "https://example.com/list/22") + + def test_create_unsupported_type(self): + """ ignore activities we know we can't handle """ + activity = self.create_json + activity["object"] = { + "id": "https://example.com/status/887", + "type": "Question", + } + # just observer how it doesn't throw an error + views.inbox.activity_task(activity) + + def test_create_unknown_type(self): + """ ignore activities we know we've never heard of """ + activity = self.create_json + activity["object"] = { + "id": "https://example.com/status/887", + "type": "Threnody", + } + with self.assertRaises(ActivitySerializerError): + views.inbox.activity_task(activity) diff --git a/bookwyrm/tests/views/inbox/test_inbox_delete.py b/bookwyrm/tests/views/inbox/test_inbox_delete.py index 65a754266..03598b88d 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_delete.py +++ b/bookwyrm/tests/views/inbox/test_inbox_delete.py @@ -49,7 +49,7 @@ class InboxActivities(TestCase): } models.SiteSettings.objects.create() - def test_handle_delete_status(self): + def test_delete_status(self): """ remove a status """ self.assertFalse(self.status.deleted) activity = { @@ -70,7 +70,7 @@ class InboxActivities(TestCase): self.assertTrue(status.deleted) self.assertIsInstance(status.deleted_date, datetime) - def test_handle_delete_status_notifications(self): + def test_delete_status_notifications(self): """ remove a status with related notifications """ models.Notification.objects.create( related_status=self.status, @@ -104,3 +104,34 @@ class InboxActivities(TestCase): # notifications should be truly deleted self.assertEqual(models.Notification.objects.count(), 1) self.assertEqual(models.Notification.objects.get(), notif) + + def test_delete_user(self): + """ delete a user """ + self.assertTrue(models.User.objects.get(username="rat@example.com").is_active) + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/test-user#delete", + "type": "Delete", + "actor": "https://example.com/users/test-user", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": self.remote_user.remote_id, + } + + views.inbox.activity_task(activity) + self.assertFalse(models.User.objects.get(username="rat@example.com").is_active) + + def test_delete_user_unknown(self): + """ don't worry about it if we don't know the user """ + self.assertEqual(models.User.objects.filter(is_active=True).count(), 2) + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/test-user#delete", + "type": "Delete", + "actor": "https://example.com/users/test-user", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "object": "https://example.com/users/test-user", + } + + # nothing happens. + views.inbox.activity_task(activity) + self.assertEqual(models.User.objects.filter(is_active=True).count(), 2) diff --git a/bookwyrm/tests/views/inbox/test_inbox_follow.py b/bookwyrm/tests/views/inbox/test_inbox_follow.py index c549c31bd..b0177cb88 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_follow.py +++ b/bookwyrm/tests/views/inbox/test_inbox_follow.py @@ -1,4 +1,5 @@ """ tests incoming activities""" +import json from unittest.mock import patch from django.test import TestCase @@ -34,7 +35,7 @@ class InboxRelationships(TestCase): models.SiteSettings.objects.create() - def test_handle_follow(self): + def test_follow(self): """ remote user wants to follow local user """ activity = { "@context": "https://www.w3.org/ns/activitystreams", @@ -48,6 +49,8 @@ class InboxRelationships(TestCase): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: views.inbox.activity_task(activity) self.assertEqual(mock.call_count, 1) + response_activity = json.loads(mock.call_args[0][1]) + self.assertEqual(response_activity["type"], "Accept") # notification created notification = models.Notification.objects.get() @@ -61,7 +64,34 @@ class InboxRelationships(TestCase): follow = models.UserFollows.objects.get(user_object=self.local_user) self.assertEqual(follow.user_subject, self.remote_user) - def test_handle_follow_manually_approved(self): + def test_follow_duplicate(self): + """ remote user wants to follow local user twice """ + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + "id": "https://example.com/users/rat/follows/123", + "type": "Follow", + "actor": "https://example.com/users/rat", + "object": "https://example.com/user/mouse", + } + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.inbox.activity_task(activity) + + # the follow relationship should exist + follow = models.UserFollows.objects.get(user_object=self.local_user) + self.assertEqual(follow.user_subject, self.remote_user) + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: + views.inbox.activity_task(activity) + self.assertEqual(mock.call_count, 1) + response_activity = json.loads(mock.call_args[0][1]) + self.assertEqual(response_activity["type"], "Accept") + + # the follow relationship should STILL exist + follow = models.UserFollows.objects.get(user_object=self.local_user) + self.assertEqual(follow.user_subject, self.remote_user) + + def test_follow_manually_approved(self): """ needs approval before following """ activity = { "@context": "https://www.w3.org/ns/activitystreams", @@ -91,7 +121,7 @@ class InboxRelationships(TestCase): follow = models.UserFollows.objects.all() self.assertEqual(list(follow), []) - def test_handle_undo_follow_request(self): + def test_undo_follow_request(self): """ the requester cancels a follow request """ self.local_user.manually_approves_followers = True self.local_user.save(broadcast=False) @@ -121,7 +151,7 @@ class InboxRelationships(TestCase): self.assertFalse(self.local_user.follower_requests.exists()) - def test_handle_unfollow(self): + def test_unfollow(self): """ remove a relationship """ with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): rel = models.UserFollows.objects.create( @@ -146,7 +176,7 @@ class InboxRelationships(TestCase): views.inbox.activity_task(activity) self.assertIsNone(self.local_user.followers.first()) - def test_handle_follow_accept(self): + def test_follow_accept(self): """ a remote user approved a follow request from local """ with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): rel = models.UserFollowRequest.objects.create( @@ -177,7 +207,7 @@ class InboxRelationships(TestCase): self.assertEqual(follows.count(), 1) self.assertEqual(follows.first(), self.local_user) - def test_handle_follow_reject(self): + def test_follow_reject(self): """ turn down a follow request """ with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): rel = models.UserFollowRequest.objects.create( diff --git a/bookwyrm/tests/views/inbox/test_inbox_remove.py b/bookwyrm/tests/views/inbox/test_inbox_remove.py index a17154d11..8ac8740ad 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_remove.py +++ b/bookwyrm/tests/views/inbox/test_inbox_remove.py @@ -80,6 +80,7 @@ class InboxRemove(TestCase): user=self.local_user, book=self.book, book_list=booklist, + order=1, ) self.assertEqual(booklist.books.count(), 1) diff --git a/bookwyrm/tests/views/inbox/test_inbox_update.py b/bookwyrm/tests/views/inbox/test_inbox_update.py index 012343e78..5681ec882 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_update.py +++ b/bookwyrm/tests/views/inbox/test_inbox_update.py @@ -23,6 +23,16 @@ class InboxUpdate(TestCase): ) self.local_user.remote_id = "https://example.com/user/mouse" self.local_user.save(broadcast=False) + with patch("bookwyrm.models.user.set_remote_server.delay"): + self.remote_user = models.User.objects.create_user( + "rat", + "rat@rat.com", + "ratword", + local=False, + remote_id="https://example.com/users/rat", + inbox="https://example.com/users/rat/inbox", + outbox="https://example.com/users/rat/outbox", + ) self.create_json = { "id": "hi", @@ -34,7 +44,7 @@ class InboxUpdate(TestCase): } models.SiteSettings.objects.create() - def test_handle_update_list(self): + def test_update_list(self): """ a new list """ with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): book_list = models.List.objects.create( @@ -68,16 +78,24 @@ class InboxUpdate(TestCase): self.assertEqual(book_list.description, "summary text") self.assertEqual(book_list.remote_id, "https://example.com/list/22") - def test_handle_update_user(self): + def test_update_user(self): """ update an existing user """ - # we only do this with remote users - self.local_user.local = False - self.local_user.save() + models.UserFollows.objects.create( + user_subject=self.local_user, + user_object=self.remote_user, + ) + models.UserFollows.objects.create( + user_subject=self.remote_user, + user_object=self.local_user, + ) + self.assertTrue(self.remote_user in self.local_user.followers.all()) + self.assertTrue(self.local_user in self.remote_user.followers.all()) - datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_user.json") + datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_user_rat.json") userdata = json.loads(datafile.read_bytes()) del userdata["icon"] - self.assertIsNone(self.local_user.name) + self.assertIsNone(self.remote_user.name) + self.assertFalse(self.remote_user.discoverable) views.inbox.activity_task( { "type": "Update", @@ -88,13 +106,16 @@ class InboxUpdate(TestCase): "object": userdata, } ) - user = models.User.objects.get(id=self.local_user.id) - self.assertEqual(user.name, "MOUSE?? MOUSE!!") - self.assertEqual(user.username, "mouse@example.com") - self.assertEqual(user.localname, "mouse") + user = models.User.objects.get(id=self.remote_user.id) + self.assertEqual(user.name, "RAT???") + self.assertEqual(user.username, "rat@example.com") self.assertTrue(user.discoverable) - def test_handle_update_edition(self): + # make sure relationships aren't disrupted + self.assertTrue(self.remote_user in self.local_user.followers.all()) + self.assertTrue(self.local_user in self.remote_user.followers.all()) + + def test_update_edition(self): """ update an existing edition """ datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_edition.json") bookdata = json.loads(datafile.read_bytes()) @@ -122,8 +143,9 @@ class InboxUpdate(TestCase): ) book = models.Edition.objects.get(id=book.id) self.assertEqual(book.title, "Piranesi") + self.assertEqual(book.last_edited_by, self.remote_user) - def test_handle_update_work(self): + def test_update_work(self): """ update an existing edition """ datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_work.json") bookdata = json.loads(datafile.read_bytes()) diff --git a/bookwyrm/tests/views/test_book.py b/bookwyrm/tests/views/test_book.py index ade6131d0..a0fa03676 100644 --- a/bookwyrm/tests/views/test_book.py +++ b/bookwyrm/tests/views/test_book.py @@ -47,6 +47,39 @@ class BookViews(TestCase): ) models.SiteSettings.objects.create() + def test_date_regression(self): + """ensure that creating a new book actually saves the published date fields + + this was initially a regression due to using a custom date picker tag + """ + first_published_date = "2021-04-20" + published_date = "2022-04-20" + self.local_user.groups.add(self.group) + view = views.EditBook.as_view() + form = forms.EditionForm( + { + "title": "New Title", + "last_edited_by": self.local_user.id, + "first_published_date": first_published_date, + "published_date": published_date, + } + ) + request = self.factory.post("", form.data) + request.user = self.local_user + + with patch("bookwyrm.connectors.connector_manager.local_search"): + result = view(request) + result.render() + + self.assertContains( + result, + f'', + ) + self.assertContains( + result, + f'', + ) + def test_book_page(self): """ there are so many views, this just makes sure it LOADS """ view = views.Book.as_view() diff --git a/bookwyrm/tests/views/test_federation.py b/bookwyrm/tests/views/test_federation.py index a60ea4327..2afdd6d3c 100644 --- a/bookwyrm/tests/views/test_federation.py +++ b/bookwyrm/tests/views/test_federation.py @@ -1,9 +1,12 @@ """ test for app action functionality """ +import json +from unittest.mock import patch +from django.core.files.uploadedfile import SimpleUploadedFile from django.template.response import TemplateResponse from django.test import TestCase from django.test.client import RequestFactory -from bookwyrm import models, views +from bookwyrm import forms, models, views class FederationViews(TestCase): @@ -19,6 +22,16 @@ class FederationViews(TestCase): local=True, localname="mouse", ) + with patch("bookwyrm.models.user.set_remote_server.delay"): + self.remote_user = models.User.objects.create_user( + "rat", + "rat@rat.com", + "ratword", + local=False, + remote_id="https://example.com/users/rat", + inbox="https://example.com/users/rat/inbox", + outbox="https://example.com/users/rat/outbox", + ) models.SiteSettings.objects.create() def test_federation_page(self): @@ -44,3 +57,111 @@ class FederationViews(TestCase): self.assertIsInstance(result, TemplateResponse) result.render() self.assertEqual(result.status_code, 200) + + def test_server_page_block(self): + """ block a server """ + server = models.FederatedServer.objects.create(server_name="hi.there.com") + self.remote_user.federated_server = server + self.remote_user.save() + + self.assertEqual(server.status, "federated") + + view = views.federation.block_server + request = self.factory.post("") + request.user = self.local_user + request.user.is_superuser = True + + view(request, server.id) + server.refresh_from_db() + self.remote_user.refresh_from_db() + self.assertEqual(server.status, "blocked") + # and the user was deactivated + self.assertFalse(self.remote_user.is_active) + + def test_server_page_unblock(self): + """ unblock a server """ + server = models.FederatedServer.objects.create( + server_name="hi.there.com", status="blocked" + ) + self.remote_user.federated_server = server + self.remote_user.is_active = False + self.remote_user.deactivation_reason = "domain_block" + self.remote_user.save() + + request = self.factory.post("") + request.user = self.local_user + request.user.is_superuser = True + + views.federation.unblock_server(request, server.id) + server.refresh_from_db() + self.remote_user.refresh_from_db() + self.assertEqual(server.status, "federated") + # and the user was re-activated + self.assertTrue(self.remote_user.is_active) + + def test_add_view_get(self): + """ there are so many views, this just makes sure it LOADS """ + # create mode + view = views.AddFederatedServer.as_view() + request = self.factory.get("") + request.user = self.local_user + request.user.is_superuser = True + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + result.render() + self.assertEqual(result.status_code, 200) + + def test_add_view_post_create(self): + """ create a server entry """ + form = forms.ServerForm() + form.data["server_name"] = "remote.server" + form.data["application_type"] = "coolsoft" + form.data["status"] = "blocked" + + view = views.AddFederatedServer.as_view() + request = self.factory.post("", form.data) + request.user = self.local_user + request.user.is_superuser = True + + view(request) + server = models.FederatedServer.objects.get() + self.assertEqual(server.server_name, "remote.server") + self.assertEqual(server.application_type, "coolsoft") + self.assertEqual(server.status, "blocked") + + def test_import_blocklist(self): + """ load a json file with a list of servers to block """ + server = models.FederatedServer.objects.create(server_name="hi.there.com") + self.remote_user.federated_server = server + self.remote_user.save() + + data = [ + {"instance": "server.name", "url": "https://explanation.url"}, # new server + {"instance": "hi.there.com", "url": "https://explanation.url"}, # existing + {"a": "b"}, # invalid + ] + json.dump(data, open("file.json", "w")) + + view = views.ImportServerBlocklist.as_view() + request = self.factory.post( + "", + { + "json_file": SimpleUploadedFile( + "file.json", open("file.json", "rb").read() + ) + }, + ) + request.user = self.local_user + request.user.is_superuser = True + + view(request) + server.refresh_from_db() + self.remote_user.refresh_from_db() + + self.assertEqual(models.FederatedServer.objects.count(), 2) + self.assertEqual(server.status, "blocked") + self.assertFalse(self.remote_user.is_active) + created = models.FederatedServer.objects.get(server_name="server.name") + self.assertEqual(created.status, "blocked") + self.assertEqual(created.notes, "https://explanation.url") diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py index 7d2bc42c9..2e5ed82d4 100644 --- a/bookwyrm/tests/views/test_helpers.py +++ b/bookwyrm/tests/views/test_helpers.py @@ -146,6 +146,15 @@ class ViewsHelpers(TestCase): self.assertIsInstance(result, models.User) self.assertEqual(result.username, "mouse@example.com") + def test_user_on_blocked_server(self, _): + """ find a remote user using webfinger """ + models.FederatedServer.objects.create( + server_name="example.com", status="blocked" + ) + + result = views.helpers.handle_remote_webfinger("@mouse@example.com") + self.assertIsNone(result) + def test_handle_reading_status_to_read(self, _): """ posts shelve activities """ shelf = self.local_user.shelf_set.get(identifier="to-read") @@ -190,66 +199,6 @@ class ViewsHelpers(TestCase): ) self.assertFalse(models.GeneratedNote.objects.exists()) - def test_object_visible_to_user(self, _): - """ does a user have permission to view an object """ - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="public" - ) - self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj)) - - obj = models.Shelf.objects.create( - name="test", user=self.remote_user, privacy="unlisted" - ) - self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj)) - - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="followers" - ) - self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj)) - - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="direct" - ) - self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj)) - - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="direct" - ) - obj.mention_users.add(self.local_user) - self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj)) - - def test_object_visible_to_user_follower(self, _): - """ what you can see if you follow a user """ - self.remote_user.followers.add(self.local_user) - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="followers" - ) - self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj)) - - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="direct" - ) - self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj)) - - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="direct" - ) - obj.mention_users.add(self.local_user) - self.assertTrue(views.helpers.object_visible_to_user(self.local_user, obj)) - - def test_object_visible_to_user_blocked(self, _): - """ you can't see it if they block you """ - self.remote_user.blocks.add(self.local_user) - obj = models.Status.objects.create( - content="hi", user=self.remote_user, privacy="public" - ) - self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj)) - - obj = models.Shelf.objects.create( - name="test", user=self.remote_user, privacy="unlisted" - ) - self.assertFalse(views.helpers.object_visible_to_user(self.local_user, obj)) - def test_get_annotated_users(self, _): """ list of people you might know """ user_1 = models.User.objects.create_user( diff --git a/bookwyrm/tests/views/test_list.py b/bookwyrm/tests/views/test_list.py index 0a851bf0c..215f3e61e 100644 --- a/bookwyrm/tests/views/test_list.py +++ b/bookwyrm/tests/views/test_list.py @@ -39,6 +39,25 @@ class ListViews(TestCase): remote_id="https://example.com/book/1", parent_work=work, ) + work_two = models.Work.objects.create(title="Labori") + self.book_two = models.Edition.objects.create( + title="Example Edition 2", + remote_id="https://example.com/book/2", + parent_work=work_two, + ) + work_three = models.Work.objects.create(title="Trabajar") + self.book_three = models.Edition.objects.create( + title="Example Edition 3", + remote_id="https://example.com/book/3", + parent_work=work_three, + ) + work_four = models.Work.objects.create(title="Travailler") + self.book_four = models.Edition.objects.create( + title="Example Edition 4", + remote_id="https://example.com/book/4", + parent_work=work_four, + ) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): self.list = models.List.objects.create( name="Test List", user=self.local_user @@ -194,6 +213,7 @@ class ListViews(TestCase): user=self.local_user, book=self.book, approved=False, + order=1, ) request = self.factory.post( @@ -208,7 +228,7 @@ class ListViews(TestCase): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: view(request, self.list.id) - self.assertEqual(mock.call_count, 1) + self.assertEqual(mock.call_count, 2) activity = json.loads(mock.call_args[0][1]) self.assertEqual(activity["type"], "Add") self.assertEqual(activity["actor"], self.local_user.remote_id) @@ -228,6 +248,7 @@ class ListViews(TestCase): user=self.local_user, book=self.book, approved=False, + order=1, ) request = self.factory.post( @@ -268,6 +289,261 @@ class ListViews(TestCase): self.assertEqual(item.user, self.local_user) self.assertTrue(item.approved) + def test_add_two_books(self): + """ + Putting two books on the list. The first should have an order value of + 1 and the second should have an order value of 2. + """ + request_one = self.factory.post( + "", + { + "book": self.book.id, + "list": self.list.id, + }, + ) + request_one.user = self.local_user + + request_two = self.factory.post( + "", + { + "book": self.book_two.id, + "list": self.list.id, + }, + ) + request_two.user = self.local_user + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.list.add_book(request_one) + views.list.add_book(request_two) + + items = self.list.listitem_set.order_by("order").all() + self.assertEqual(items[0].book, self.book) + self.assertEqual(items[1].book, self.book_two) + self.assertEqual(items[0].order, 1) + self.assertEqual(items[1].order, 2) + + def test_add_three_books_and_remove_second(self): + """ + Put three books on a list and then remove the one in the middle. The + ordering of the list should adjust to not have a gap. + """ + request_one = self.factory.post( + "", + { + "book": self.book.id, + "list": self.list.id, + }, + ) + request_one.user = self.local_user + + request_two = self.factory.post( + "", + { + "book": self.book_two.id, + "list": self.list.id, + }, + ) + request_two.user = self.local_user + + request_three = self.factory.post( + "", + { + "book": self.book_three.id, + "list": self.list.id, + }, + ) + request_three.user = self.local_user + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.list.add_book(request_one) + views.list.add_book(request_two) + views.list.add_book(request_three) + + items = self.list.listitem_set.order_by("order").all() + self.assertEqual(items[0].book, self.book) + self.assertEqual(items[1].book, self.book_two) + self.assertEqual(items[2].book, self.book_three) + self.assertEqual(items[0].order, 1) + self.assertEqual(items[1].order, 2) + self.assertEqual(items[2].order, 3) + + remove_request = self.factory.post("", {"item": items[1].id}) + remove_request.user = self.local_user + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.list.remove_book(remove_request, self.list.id) + items = self.list.listitem_set.order_by("order").all() + self.assertEqual(items[0].book, self.book) + self.assertEqual(items[1].book, self.book_three) + self.assertEqual(items[0].order, 1) + self.assertEqual(items[1].order, 2) + + def test_adding_book_with_a_pending_book(self): + """ + When a list contains any pending books, the pending books should have + be at the end of the list by order. If a book is added while a book is + pending, its order should precede the pending books. + """ + request = self.factory.post( + "", + { + "book": self.book_three.id, + "list": self.list.id, + }, + ) + request.user = self.local_user + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + models.ListItem.objects.create( + book_list=self.list, + user=self.local_user, + book=self.book, + approved=True, + order=1, + ) + models.ListItem.objects.create( + book_list=self.list, + user=self.rat, + book=self.book_two, + approved=False, + order=2, + ) + views.list.add_book(request) + + items = self.list.listitem_set.order_by("order").all() + self.assertEqual(items[0].book, self.book) + self.assertEqual(items[0].order, 1) + self.assertTrue(items[0].approved) + + self.assertEqual(items[1].book, self.book_three) + self.assertEqual(items[1].order, 2) + self.assertTrue(items[1].approved) + + self.assertEqual(items[2].book, self.book_two) + self.assertEqual(items[2].order, 3) + self.assertFalse(items[2].approved) + + def test_approving_one_pending_book_from_multiple(self): + """ + When a list contains any pending books, the pending books should have + be at the end of the list by order. If a pending book is approved, then + its order should be at the end of the approved books and before the + remaining pending books. + """ + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + models.ListItem.objects.create( + book_list=self.list, + user=self.local_user, + book=self.book, + approved=True, + order=1, + ) + models.ListItem.objects.create( + book_list=self.list, + user=self.local_user, + book=self.book_two, + approved=True, + order=2, + ) + models.ListItem.objects.create( + book_list=self.list, + user=self.rat, + book=self.book_three, + approved=False, + order=3, + ) + to_be_approved = models.ListItem.objects.create( + book_list=self.list, + user=self.rat, + book=self.book_four, + approved=False, + order=4, + ) + + view = views.Curate.as_view() + request = self.factory.post( + "", + { + "item": to_be_approved.id, + "approved": "true", + }, + ) + request.user = self.local_user + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + view(request, self.list.id) + + items = self.list.listitem_set.order_by("order").all() + self.assertEqual(items[0].book, self.book) + self.assertEqual(items[0].order, 1) + self.assertTrue(items[0].approved) + + self.assertEqual(items[1].book, self.book_two) + self.assertEqual(items[1].order, 2) + self.assertTrue(items[1].approved) + + self.assertEqual(items[2].book, self.book_four) + self.assertEqual(items[2].order, 3) + self.assertTrue(items[2].approved) + + self.assertEqual(items[3].book, self.book_three) + self.assertEqual(items[3].order, 4) + self.assertFalse(items[3].approved) + + def test_add_three_books_and_move_last_to_first(self): + """ + Put three books on the list and move the last book to the first + position. + """ + request_one = self.factory.post( + "", + { + "book": self.book.id, + "list": self.list.id, + }, + ) + request_one.user = self.local_user + + request_two = self.factory.post( + "", + { + "book": self.book_two.id, + "list": self.list.id, + }, + ) + request_two.user = self.local_user + + request_three = self.factory.post( + "", + { + "book": self.book_three.id, + "list": self.list.id, + }, + ) + request_three.user = self.local_user + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.list.add_book(request_one) + views.list.add_book(request_two) + views.list.add_book(request_three) + + items = self.list.listitem_set.order_by("order").all() + self.assertEqual(items[0].book, self.book) + self.assertEqual(items[1].book, self.book_two) + self.assertEqual(items[2].book, self.book_three) + self.assertEqual(items[0].order, 1) + self.assertEqual(items[1].order, 2) + self.assertEqual(items[2].order, 3) + + set_position_request = self.factory.post("", {"position": 1}) + set_position_request.user = self.local_user + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.list.set_book_position(set_position_request, items[2].id) + items = self.list.listitem_set.order_by("order").all() + self.assertEqual(items[0].book, self.book_three) + self.assertEqual(items[1].book, self.book) + self.assertEqual(items[2].book, self.book_two) + self.assertEqual(items[0].order, 1) + self.assertEqual(items[1].order, 2) + self.assertEqual(items[2].order, 3) + def test_add_book_outsider(self): """ put a book on a list """ self.list.curation = "open" @@ -358,6 +634,7 @@ class ListViews(TestCase): book_list=self.list, user=self.local_user, book=self.book, + order=1, ) self.assertTrue(self.list.listitem_set.exists()) @@ -369,16 +646,15 @@ class ListViews(TestCase): ) request.user = self.local_user - views.list.remove_book(request, self.list.id) + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + views.list.remove_book(request, self.list.id) self.assertFalse(self.list.listitem_set.exists()) def test_remove_book_unauthorized(self): """ take an item off a list """ with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): item = models.ListItem.objects.create( - book_list=self.list, - user=self.local_user, - book=self.book, + book_list=self.list, user=self.local_user, book=self.book, order=1 ) self.assertTrue(self.list.listitem_set.exists()) request = self.factory.post( diff --git a/bookwyrm/tests/views/test_reports.py b/bookwyrm/tests/views/test_reports.py index 1c56067ad..bce19993d 100644 --- a/bookwyrm/tests/views/test_reports.py +++ b/bookwyrm/tests/views/test_reports.py @@ -1,5 +1,4 @@ """ test for app action functionality """ -from unittest.mock import patch from django.template.response import TemplateResponse from django.test import TestCase from django.test.client import RequestFactory @@ -115,22 +114,19 @@ class ReportViews(TestCase): report.refresh_from_db() self.assertFalse(report.resolved) - def test_deactivate_user(self): + def test_suspend_user(self): """ toggle whether a user is able to log in """ self.assertTrue(self.rat.is_active) - report = models.Report.objects.create(reporter=self.local_user, user=self.rat) request = self.factory.post("") request.user = self.local_user request.user.is_superuser = True # de-activate - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - views.deactivate_user(request, report.id) + views.suspend_user(request, self.rat.id) self.rat.refresh_from_db() self.assertFalse(self.rat.is_active) # re-activate - with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): - views.deactivate_user(request, report.id) + views.suspend_user(request, self.rat.id) self.rat.refresh_from_db() self.assertTrue(self.rat.is_active) diff --git a/bookwyrm/tests/views/test_user.py b/bookwyrm/tests/views/test_user.py index 055edae25..7518b2bf0 100644 --- a/bookwyrm/tests/views/test_user.py +++ b/bookwyrm/tests/views/test_user.py @@ -30,6 +30,14 @@ class UserViews(TestCase): self.rat = models.User.objects.create_user( "rat@local.com", "rat@rat.rat", "password", local=True, localname="rat" ) + self.book = models.Edition.objects.create(title="test") + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + models.ShelfBook.objects.create( + book=self.book, + user=self.local_user, + shelf=self.local_user.shelf_set.first(), + ) + models.SiteSettings.objects.create() self.anonymous_user = AnonymousUser self.anonymous_user.is_authenticated = False diff --git a/bookwyrm/tests/views/test_user_admin.py b/bookwyrm/tests/views/test_user_admin.py index dd20c1b64..b1e9d6394 100644 --- a/bookwyrm/tests/views/test_user_admin.py +++ b/bookwyrm/tests/views/test_user_admin.py @@ -1,4 +1,6 @@ """ test for app action functionality """ +from unittest.mock import patch +from django.contrib.auth.models import Group from django.template.response import TemplateResponse from django.test import TestCase from django.test.client import RequestFactory @@ -21,9 +23,9 @@ class UserAdminViews(TestCase): ) models.SiteSettings.objects.create() - def test_user_admin_page(self): + def test_user_admin_list_page(self): """ there are so many views, this just makes sure it LOADS """ - view = views.UserAdmin.as_view() + view = views.UserAdminList.as_view() request = self.factory.get("") request.user = self.local_user request.user.is_superuser = True @@ -31,3 +33,38 @@ class UserAdminViews(TestCase): self.assertIsInstance(result, TemplateResponse) result.render() self.assertEqual(result.status_code, 200) + + def test_user_admin_page(self): + """ there are so many views, this just makes sure it LOADS """ + view = views.UserAdmin.as_view() + request = self.factory.get("") + request.user = self.local_user + request.user.is_superuser = True + + result = view(request, self.local_user.id) + + self.assertIsInstance(result, TemplateResponse) + result.render() + self.assertEqual(result.status_code, 200) + + def test_user_admin_page_post(self): + """ set the user's group """ + group = Group.objects.create(name="editor") + self.assertEqual( + list(self.local_user.groups.values_list("name", flat=True)), [] + ) + + view = views.UserAdmin.as_view() + request = self.factory.post("", {"groups": [group.id]}) + request.user = self.local_user + request.user.is_superuser = True + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + result = view(request, self.local_user.id) + + self.assertIsInstance(result, TemplateResponse) + result.render() + + self.assertEqual( + list(self.local_user.groups.values_list("name", flat=True)), ["editor"] + ) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 0e48ef90c..3ad064b23 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -51,13 +51,20 @@ urlpatterns = [ r"^password-reset/(?P[A-Za-z0-9]+)/?$", views.PasswordReset.as_view() ), # admin - re_path(r"^settings/site-settings", views.Site.as_view(), name="settings-site"), + re_path(r"^settings/site-settings/?$", views.Site.as_view(), name="settings-site"), re_path( - r"^settings/email-preview", + r"^settings/email-preview/?$", views.site.email_preview, name="settings-email-preview", ), - re_path(r"^settings/users", views.UserAdmin.as_view(), name="settings-users"), + re_path( + r"^settings/users/?$", views.UserAdminList.as_view(), name="settings-users" + ), + re_path( + r"^settings/users/(?P\d+)/?$", + views.UserAdmin.as_view(), + name="settings-user", + ), re_path( r"^settings/federation/?$", views.Federation.as_view(), @@ -68,6 +75,26 @@ urlpatterns = [ views.FederatedServer.as_view(), name="settings-federated-server", ), + re_path( + r"^settings/federation/(?P\d+)/block?$", + views.federation.block_server, + name="settings-federated-server-block", + ), + re_path( + r"^settings/federation/(?P\d+)/unblock?$", + views.federation.unblock_server, + name="settings-federated-server-unblock", + ), + re_path( + r"^settings/federation/add/?$", + views.AddFederatedServer.as_view(), + name="settings-add-federated-server", + ), + re_path( + r"^settings/federation/import/?$", + views.ImportServerBlocklist.as_view(), + name="settings-import-blocklist", + ), re_path( r"^settings/invites/?$", views.ManageInvites.as_view(), name="settings-invites" ), @@ -93,9 +120,9 @@ urlpatterns = [ name="settings-report", ), re_path( - r"^settings/reports/(?P\d+)/deactivate/?$", - views.deactivate_user, - name="settings-report-deactivate", + r"^settings/reports/(?P\d+)/suspend/?$", + views.suspend_user, + name="settings-report-suspend", ), re_path( r"^settings/reports/(?P\d+)/resolve/?$", @@ -164,6 +191,11 @@ urlpatterns = [ views.list.remove_book, name="list-remove-book", ), + re_path( + r"^list-item/(?P\d+)/set-position$", + views.list.set_book_position, + name="list-set-book-position", + ), re_path( r"^list/(?P\d+)/curate/?$", views.Curate.as_view(), name="list-curate" ), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index d73a79ed0..bcd914e10 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -6,6 +6,8 @@ from .books import Book, EditBook, ConfirmEditBook, Editions from .books import upload_cover, add_description, switch_edition, resolve_book from .directory import Directory from .federation import Federation, FederatedServer +from .federation import AddFederatedServer, ImportServerBlocklist +from .federation import block_server, unblock_server from .feed import DirectMessage, Feed, Replies, Status from .follow import follow, unfollow from .follow import accept_follow_request, delete_follow_request @@ -23,7 +25,7 @@ from .notifications import Notifications from .outbox import Outbox from .reading import edit_readthrough, create_readthrough, delete_readthrough from .reading import start_reading, finish_reading, delete_progressupdate -from .reports import Report, Reports, make_report, resolve_report, deactivate_user +from .reports import Report, Reports, make_report, resolve_report, suspend_user from .rss_feed import RssFeed from .password import PasswordResetRequest, PasswordReset, ChangePassword from .search import Search @@ -34,5 +36,5 @@ from .site import Site from .status import CreateStatus, DeleteStatus, DeleteAndRedraft from .updates import get_notification_count, get_unread_status_count from .user import User, EditUser, Followers, Following -from .user_admin import UserAdmin +from .user_admin import UserAdmin, UserAdminList from .wellknown import * diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index b1b2d0656..0c61d1a20 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -1,5 +1,4 @@ """ the good stuff! the books! """ -from datetime import datetime from uuid import uuid4 from dateutil.parser import parse as dateparse @@ -31,11 +30,6 @@ class Book(View): def get(self, request, book_id): """ info about a book """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - try: book = models.Book.objects.select_subclasses().get(id=book_id) except models.Book.DoesNotExist: @@ -61,7 +55,7 @@ class Book(View): paginated = Paginator( reviews.exclude(Q(content__isnull=True) | Q(content="")), PAGE_LENGTH ) - reviews_page = paginated.page(page) + reviews_page = paginated.get_page(request.GET.get("page")) user_tags = readthroughs = user_shelves = other_edition_shelves = [] if request.user.is_authenticated: @@ -175,18 +169,18 @@ class EditBook(View): data["confirm_mode"] = True # this isn't preserved because it isn't part of the form obj data["remove_authors"] = request.POST.getlist("remove_authors") - # we have to make sure the dates are passed in as datetime, they're currently a string + # make sure the dates are passed in as datetime, they're currently a string # QueryDicts are immutable, we need to copy formcopy = data["form"].data.copy() try: formcopy["first_published_date"] = dateparse( formcopy["first_published_date"] ) - except MultiValueDictKeyError: + except (MultiValueDictKeyError, ValueError): pass try: formcopy["published_date"] = dateparse(formcopy["published_date"]) - except MultiValueDictKeyError: + except (MultiValueDictKeyError, ValueError): pass data["form"].data = formcopy return TemplateResponse(request, "book/edit_book.html", data) @@ -267,11 +261,6 @@ class Editions(View): """ list of editions of a book """ work = get_object_or_404(models.Work, id=book_id) - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - if is_api_request(request): return ActivitypubResponse(work.to_edition_list(**request.GET)) filters = {} @@ -281,12 +270,12 @@ class Editions(View): if request.GET.get("format"): filters["physical_format__iexact"] = request.GET.get("format") - editions = work.editions.order_by("-edition_rank").all() + editions = work.editions.order_by("-edition_rank") languages = set(sum([e.languages for e in editions], [])) - paginated = Paginator(editions.filter(**filters).all(), PAGE_LENGTH) + paginated = Paginator(editions.filter(**filters), PAGE_LENGTH) data = { - "editions": paginated.page(page), + "editions": paginated.get_page(request.GET.get("page")), "work": work, "languages": languages, "formats": set( diff --git a/bookwyrm/views/directory.py b/bookwyrm/views/directory.py index 9504734e8..7919dac08 100644 --- a/bookwyrm/views/directory.py +++ b/bookwyrm/views/directory.py @@ -15,12 +15,6 @@ class Directory(View): def get(self, request): """ lets see your cute faces """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - - # filters filters = {} software = request.GET.get("software") if not software or software == "bookwyrm": @@ -39,7 +33,7 @@ class Directory(View): paginated = Paginator(users, 12) data = { - "users": paginated.page(page), + "users": paginated.get_page(request.GET.get("page")), } return TemplateResponse(request, "directory/directory.html", data) diff --git a/bookwyrm/views/federation.py b/bookwyrm/views/federation.py index 464a207ca..1acacf8f3 100644 --- a/bookwyrm/views/federation.py +++ b/bookwyrm/views/federation.py @@ -1,12 +1,15 @@ """ manage federated servers """ +import json from django.contrib.auth.decorators import login_required, permission_required from django.core.paginator import Paginator -from django.shortcuts import get_object_or_404 +from django.db import transaction +from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.utils.decorators import method_decorator from django.views import View +from django.views.decorators.http import require_POST -from bookwyrm import models +from bookwyrm import forms, models from bookwyrm.settings import PAGE_LENGTH @@ -21,23 +24,78 @@ class Federation(View): def get(self, request): """ list of servers """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - - servers = models.FederatedServer.objects.all() + servers = models.FederatedServer.objects sort = request.GET.get("sort") sort_fields = ["created_date", "application_type", "server_name"] - if sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]: - servers = servers.order_by(sort) + if not sort in sort_fields + ["-{:s}".format(f) for f in sort_fields]: + sort = "created_date" + servers = servers.order_by(sort) paginated = Paginator(servers, PAGE_LENGTH) - data = {"servers": paginated.page(page), "sort": sort} + + data = { + "servers": paginated.get_page(request.GET.get("page")), + "sort": sort, + "form": forms.ServerForm(), + } return TemplateResponse(request, "settings/federation.html", data) +class AddFederatedServer(View): + """ manually add a server """ + + def get(self, request): + """ add server form """ + data = {"form": forms.ServerForm()} + return TemplateResponse(request, "settings/edit_server.html", data) + + def post(self, request): + """ add a server from the admin panel """ + form = forms.ServerForm(request.POST) + if not form.is_valid(): + data = {"form": form} + return TemplateResponse(request, "settings/edit_server.html", data) + server = form.save() + return redirect("settings-federated-server", server.id) + + +@method_decorator(login_required, name="dispatch") +@method_decorator( + permission_required("bookwyrm.control_federation", raise_exception=True), + name="dispatch", +) +class ImportServerBlocklist(View): + """ manually add a server """ + + def get(self, request): + """ add server form """ + return TemplateResponse(request, "settings/server_blocklist.html") + + def post(self, request): + """ add a server from the admin panel """ + json_data = json.load(request.FILES["json_file"]) + failed = [] + success_count = 0 + for item in json_data: + server_name = item.get("instance") + if not server_name: + failed.append(item) + continue + info_link = item.get("url") + + with transaction.atomic(): + server, _ = models.FederatedServer.objects.get_or_create( + server_name=server_name, + ) + server.notes = info_link + server.save() + server.block() + success_count += 1 + data = {"failed": failed, "succeeded": success_count} + return TemplateResponse(request, "settings/server_blocklist.html", data) + + @method_decorator(login_required, name="dispatch") @method_decorator( permission_required("bookwyrm.control_federation", raise_exception=True), @@ -61,3 +119,32 @@ class FederatedServer(View): ), } return TemplateResponse(request, "settings/federated_server.html", data) + + def post(self, request, server): # pylint: disable=unused-argument + """ update note """ + server = get_object_or_404(models.FederatedServer, id=server) + server.notes = request.POST.get("notes") + server.save() + return redirect("settings-federated-server", server.id) + + +@login_required +@require_POST +@permission_required("bookwyrm.control_federation", raise_exception=True) +# pylint: disable=unused-argument +def block_server(request, server): + """ block a server """ + server = get_object_or_404(models.FederatedServer, id=server) + server.block() + return redirect("settings-federated-server", server.id) + + +@login_required +@require_POST +@permission_required("bookwyrm.control_federation", raise_exception=True) +# pylint: disable=unused-argument +def unblock_server(request, server): + """ unblock a server """ + server = get_object_or_404(models.FederatedServer, id=server) + server.unblock() + return redirect("settings-federated-server", server.id) diff --git a/bookwyrm/views/feed.py b/bookwyrm/views/feed.py index cda115867..118473d03 100644 --- a/bookwyrm/views/feed.py +++ b/bookwyrm/views/feed.py @@ -12,7 +12,7 @@ from bookwyrm import activitystreams, forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH, STREAMS from .helpers import get_user_from_username, privacy_filter, get_suggested_users -from .helpers import is_api_request, is_bookwyrm_request, object_visible_to_user +from .helpers import is_api_request, is_bookwyrm_request # pylint: disable= no-self-use @@ -22,11 +22,6 @@ class Feed(View): def get(self, request, tab): """ user's homepage with activity feed """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - if not tab in STREAMS: tab = "home" @@ -39,7 +34,7 @@ class Feed(View): **feed_page_data(request.user), **{ "user": request.user, - "activities": paginated.page(page), + "activities": paginated.get_page(request.GET.get("page")), "suggested_users": suggested_users, "tab": tab, "goal_form": forms.GoalForm(), @@ -55,11 +50,6 @@ class DirectMessage(View): def get(self, request, username=None): """ like a feed but for dms only """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - # remove fancy subclasses of status, keep just good ol' notes queryset = models.Status.objects.filter( review__isnull=True, @@ -82,13 +72,12 @@ class DirectMessage(View): ).order_by("-published_date") paginated = Paginator(activities, PAGE_LENGTH) - activity_page = paginated.page(page) data = { **feed_page_data(request.user), **{ "user": request.user, "partner": user, - "activities": activity_page, + "activities": paginated.get_page(request.GET.get("page")), "path": "/direct-messages", }, } @@ -105,7 +94,7 @@ class Status(View): status = models.Status.objects.select_subclasses().get( id=status_id, deleted=False ) - except (ValueError, models.Status.DoesNotExist): + except (ValueError, models.Status.DoesNotExist, models.User.DoesNotExist): return HttpResponseNotFound() # the url should have the poster's username in it @@ -113,7 +102,7 @@ class Status(View): return HttpResponseNotFound() # make sure the user is authorized to see the status - if not object_visible_to_user(request.user, status): + if not status.visible_to_user(request.user): return HttpResponseNotFound() if is_api_request(request): @@ -174,7 +163,7 @@ def get_suggested_books(user, max_books=5): ) shelf = user.shelf_set.get(identifier=preset) - shelf_books = shelf.shelfbook_set.order_by("-updated_date").all()[:limit] + shelf_books = shelf.shelfbook_set.order_by("-updated_date")[:limit] if not shelf_books: continue shelf_preview = { diff --git a/bookwyrm/views/goal.py b/bookwyrm/views/goal.py index 9c4e117c6..1627d3da3 100644 --- a/bookwyrm/views/goal.py +++ b/bookwyrm/views/goal.py @@ -10,7 +10,7 @@ from django.views.decorators.http import require_POST from bookwyrm import forms, models from bookwyrm.status import create_generated_note -from .helpers import get_user_from_username, object_visible_to_user +from .helpers import get_user_from_username # pylint: disable= no-self-use @@ -26,7 +26,7 @@ class Goal(View): if not goal and user != request.user: return HttpResponseNotFound() - if goal and not object_visible_to_user(request.user, goal): + if goal and not goal.visible_to_user(request.user): return HttpResponseNotFound() data = { diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 2b6501ff2..57c334377 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -32,30 +32,6 @@ def is_bookwyrm_request(request): return True -def object_visible_to_user(viewer, obj): - """ is a user authorized to view an object? """ - if not obj: - return False - - # viewer can't see it if the object's owner blocked them - if viewer in obj.user.blocks.all(): - return False - - # you can see your own posts and any public or unlisted posts - if viewer == obj.user or obj.privacy in ["public", "unlisted"]: - return True - - # you can see the followers only posts of people you follow - if obj.privacy == "followers" and obj.user.followers.filter(id=viewer.id).first(): - return True - - # you can see dms you are tagged in - if isinstance(obj, models.Status): - if obj.privacy == "direct" and obj.mention_users.filter(id=viewer.id).first(): - return True - return False - - def privacy_filter(viewer, queryset, privacy_levels=None, following_only=False): """ filter objects that have "user" and "privacy" fields """ privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"] diff --git a/bookwyrm/views/inbox.py b/bookwyrm/views/inbox.py index 8c645159e..c701956d2 100644 --- a/bookwyrm/views/inbox.py +++ b/bookwyrm/views/inbox.py @@ -1,9 +1,10 @@ """ incoming activities """ import json +import re from urllib.parse import urldefrag -from django.http import HttpResponse -from django.http import HttpResponseBadRequest, HttpResponseNotFound +from django.http import HttpResponse, HttpResponseNotFound +from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt @@ -12,6 +13,7 @@ import requests from bookwyrm import activitypub, models from bookwyrm.tasks import app from bookwyrm.signatures import Signature +from bookwyrm.utils import regex @method_decorator(csrf_exempt, name="dispatch") @@ -21,6 +23,10 @@ class Inbox(View): def post(self, request, username=None): """ only works as POST request """ + # first check if this server is on our shitlist + if is_blocked_user_agent(request): + return HttpResponseForbidden() + # make sure the user's inbox even exists if username: try: @@ -34,6 +40,10 @@ class Inbox(View): except json.decoder.JSONDecodeError: return HttpResponseBadRequest() + # let's be extra sure we didn't block this domain + if is_blocked_activity(activity_json): + return HttpResponseForbidden() + if ( not "object" in activity_json or not "type" in activity_json @@ -54,6 +64,34 @@ class Inbox(View): return HttpResponse() +def is_blocked_user_agent(request): + """ check if a request is from a blocked server based on user agent """ + # check user agent + user_agent = request.headers.get("User-Agent") + if not user_agent: + return False + url = re.search(r"https?://{:s}/?".format(regex.domain), user_agent) + if not url: + return False + url = url.group() + return models.FederatedServer.is_blocked(url) + + +def is_blocked_activity(activity_json): + """ get the sender out of activity json and check if it's blocked """ + actor = activity_json.get("actor") + + # check if the user is banned/deleted + existing = models.User.find_existing_by_remote_id(actor) + if existing and existing.deleted: + return True + + if not actor: + # well I guess it's not even a valid activity so who knows + return False + return models.FederatedServer.is_blocked(actor) + + @app.task def activity_task(activity_json): """ do something with this json we think is legit """ diff --git a/bookwyrm/views/invite.py b/bookwyrm/views/invite.py index 61f031ef5..cbb189b5d 100644 --- a/bookwyrm/views/invite.py +++ b/bookwyrm/views/invite.py @@ -30,11 +30,6 @@ class ManageInvites(View): def get(self, request): """ invite management page """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - paginated = Paginator( models.SiteInvite.objects.filter(user=request.user).order_by( "-created_date" @@ -43,7 +38,7 @@ class ManageInvites(View): ) data = { - "invites": paginated.page(page), + "invites": paginated.get_page(request.GET.get("page")), "form": forms.CreateInviteForm(), } return TemplateResponse(request, "settings/manage_invites.html", data) @@ -93,11 +88,6 @@ class ManageInviteRequests(View): def get(self, request): """ view a list of requests """ ignored = request.GET.get("ignored", False) - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - sort = request.GET.get("sort") sort_fields = [ "created_date", @@ -136,7 +126,7 @@ class ManageInviteRequests(View): data = { "ignored": ignored, "count": paginated.count, - "requests": paginated.page(page), + "requests": paginated.get_page(request.GET.get("page")), "sort": sort, } return TemplateResponse(request, "settings/manage_invite_requests.html", data) diff --git a/bookwyrm/views/list.py b/bookwyrm/views/list.py index 7724cd137..a2cf7afe9 100644 --- a/bookwyrm/views/list.py +++ b/bookwyrm/views/list.py @@ -1,9 +1,12 @@ """ book list views""" +from typing import Optional + from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator -from django.db import IntegrityError -from django.db.models import Count, Q -from django.http import HttpResponseNotFound, HttpResponseBadRequest +from django.db import IntegrityError, transaction +from django.db.models import Avg, Count, Q, Max +from django.db.models.functions import Coalesce +from django.http import HttpResponseNotFound, HttpResponseBadRequest, HttpResponse from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.utils.decorators import method_decorator @@ -13,20 +16,16 @@ from django.views.decorators.http import require_POST from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.connectors import connector_manager -from .helpers import is_api_request, object_visible_to_user, privacy_filter +from .helpers import is_api_request, privacy_filter from .helpers import get_user_from_username + # pylint: disable=no-self-use class Lists(View): """ book list page """ def get(self, request): """ display a book list """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - # hide lists with no approved books lists = ( models.List.objects.annotate( @@ -35,7 +34,6 @@ class Lists(View): .filter(item_count__gt=0) .order_by("-updated_date") .distinct() - .all() ) lists = privacy_filter( @@ -44,7 +42,7 @@ class Lists(View): paginated = Paginator(lists, 12) data = { - "lists": paginated.page(page), + "lists": paginated.get_page(request.GET.get("page")), "list_form": forms.ListForm(), "path": "/list", } @@ -67,19 +65,15 @@ class UserLists(View): def get(self, request, username): """ display a book list """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 user = get_user_from_username(request.user, username) - lists = models.List.objects.filter(user=user).all() + lists = models.List.objects.filter(user=user) lists = privacy_filter(request.user, lists) paginated = Paginator(lists, 12) data = { "user": user, "is_self": request.user.id == user.id, - "lists": paginated.page(page), + "lists": paginated.get_page(request.GET.get("page")), "list_form": forms.ListForm(), "path": user.local_path + "/lists", } @@ -92,7 +86,7 @@ class List(View): def get(self, request, list_id): """ display a book list """ book_list = get_object_or_404(models.List, id=list_id) - if not object_visible_to_user(request.user, book_list): + if not book_list.visible_to_user(request.user): return HttpResponseNotFound() if is_api_request(request): @@ -100,6 +94,45 @@ class List(View): query = request.GET.get("q") suggestions = None + + # sort_by shall be "order" unless a valid alternative is given + sort_by = request.GET.get("sort_by", "order") + if sort_by not in ("order", "title", "rating"): + sort_by = "order" + + # direction shall be "ascending" unless a valid alternative is given + direction = request.GET.get("direction", "ascending") + if direction not in ("ascending", "descending"): + direction = "ascending" + + internal_sort_by = { + "order": "order", + "title": "book__title", + "rating": "average_rating", + } + directional_sort_by = internal_sort_by[sort_by] + if direction == "descending": + directional_sort_by = "-" + directional_sort_by + + if sort_by == "order": + items = book_list.listitem_set.filter(approved=True).order_by( + directional_sort_by + ) + elif sort_by == "title": + items = book_list.listitem_set.filter(approved=True).order_by( + directional_sort_by + ) + elif sort_by == "rating": + items = ( + book_list.listitem_set.annotate( + average_rating=Avg(Coalesce("book__review__rating", 0)) + ) + .filter(approved=True) + .order_by(directional_sort_by) + ) + + paginated = Paginator(items, 12) + if query and request.user.is_authenticated: # search for books suggestions = connector_manager.local_search(query, raw=True) @@ -119,11 +152,14 @@ class List(View): data = { "list": book_list, - "items": book_list.listitem_set.filter(approved=True), + "items": paginated.get_page(request.GET.get("page")), "pending_count": book_list.listitem_set.filter(approved=False).count(), "suggested_books": suggestions, "list_form": forms.ListForm(instance=book_list), "query": query or "", + "sort_form": forms.SortListForm( + {"direction": direction, "sort_by": sort_by} + ), } return TemplateResponse(request, "lists/list.html", data) @@ -165,10 +201,22 @@ class Curate(View): suggestion = get_object_or_404(models.ListItem, id=request.POST.get("item")) approved = request.POST.get("approved") == "true" if approved: + # update the book and set it to be the last in the order of approved books, + # before any pending books suggestion.approved = True + order_max = ( + book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[ + "order__max" + ] + or 0 + ) + 1 + suggestion.order = order_max + increment_order_in_reverse(book_list.id, order_max) suggestion.save() else: - suggestion.delete() + deleted_order = suggestion.order + suggestion.delete(broadcast=False) + normalize_book_list_ordering(book_list.id, start=deleted_order) return redirect("list-curate", book_list.id) @@ -176,26 +224,37 @@ class Curate(View): def add_book(request): """ put a book on a list """ book_list = get_object_or_404(models.List, id=request.POST.get("list")) - if not object_visible_to_user(request.user, book_list): + if not book_list.visible_to_user(request.user): return HttpResponseNotFound() book = get_object_or_404(models.Edition, id=request.POST.get("book")) # do you have permission to add to the list? try: if request.user == book_list.user or book_list.curation == "open": - # go ahead and add it + # add the book at the latest order of approved books, before pending books + order_max = ( + book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[ + "order__max" + ] + ) or 0 + increment_order_in_reverse(book_list.id, order_max + 1) models.ListItem.objects.create( book=book, book_list=book_list, user=request.user, + order=order_max + 1, ) elif book_list.curation == "curated": - # make a pending entry + # make a pending entry at the end of the list + order_max = ( + book_list.listitem_set.aggregate(Max("order"))["order__max"] + ) or 0 models.ListItem.objects.create( approved=False, book=book, book_list=book_list, user=request.user, + order=order_max + 1, ) else: # you can't add to this list, what were you THINKING @@ -209,12 +268,113 @@ def add_book(request): @require_POST def remove_book(request, list_id): - """ put a book on a list """ - book_list = get_object_or_404(models.List, id=list_id) - item = get_object_or_404(models.ListItem, id=request.POST.get("item")) + """ remove a book from a list """ + with transaction.atomic(): + book_list = get_object_or_404(models.List, id=list_id) + item = get_object_or_404(models.ListItem, id=request.POST.get("item")) - if not book_list.user == request.user and not item.user == request.user: - return HttpResponseNotFound() + if not book_list.user == request.user and not item.user == request.user: + return HttpResponseNotFound() - item.delete() + deleted_order = item.order + item.delete() + normalize_book_list_ordering(book_list.id, start=deleted_order) return redirect("list", list_id) + + +@require_POST +def set_book_position(request, list_item_id): + """ + Action for when the list user manually specifies a list position, takes + special care with the unique ordering per list. + """ + with transaction.atomic(): + list_item = get_object_or_404(models.ListItem, id=list_item_id) + try: + int_position = int(request.POST.get("position")) + except ValueError: + return HttpResponseBadRequest( + "bad value for position. should be an integer" + ) + + if int_position < 1: + return HttpResponseBadRequest("position cannot be less than 1") + + book_list = list_item.book_list + + # the max position to which a book may be set is the highest order for + # books which are approved + order_max = book_list.listitem_set.filter(approved=True).aggregate( + Max("order") + )["order__max"] + + if int_position > order_max: + int_position = order_max + + if request.user not in (book_list.user, list_item.user): + return HttpResponseNotFound() + + original_order = list_item.order + if original_order == int_position: + return HttpResponse(status=204) + if original_order > int_position: + list_item.order = -1 + list_item.save() + increment_order_in_reverse(book_list.id, int_position, original_order) + else: + list_item.order = -1 + list_item.save() + decrement_order(book_list.id, original_order, int_position) + + list_item.order = int_position + list_item.save() + + return redirect("list", book_list.id) + + +@transaction.atomic +def increment_order_in_reverse( + book_list_id: int, start: int, end: Optional[int] = None +): + """ increase the order nu,ber for every item in a list """ + try: + book_list = models.List.objects.get(id=book_list_id) + except models.List.DoesNotExist: + return + items = book_list.listitem_set.filter(order__gte=start) + if end is not None: + items = items.filter(order__lt=end) + items = items.order_by("-order") + for item in items: + item.order += 1 + item.save() + + +@transaction.atomic +def decrement_order(book_list_id, start, end): + """ decrement the order value for every item in a list """ + try: + book_list = models.List.objects.get(id=book_list_id) + except models.List.DoesNotExist: + return + items = book_list.listitem_set.filter(order__gt=start, order__lte=end).order_by( + "order" + ) + for item in items: + item.order -= 1 + item.save() + + +@transaction.atomic +def normalize_book_list_ordering(book_list_id, start=0, add_offset=0): + """ gives each book in a list the proper sequential order number """ + try: + book_list = models.List.objects.get(id=book_list_id) + except models.List.DoesNotExist: + return + items = book_list.listitem_set.filter(order__gt=start).order_by("order") + for i, item in enumerate(items, start): + effective_order = i + add_offset + if item.order != effective_order: + item.order = effective_order + item.save() diff --git a/bookwyrm/views/reading.py b/bookwyrm/views/reading.py index b780dd2fd..f2d5b2c2d 100644 --- a/bookwyrm/views/reading.py +++ b/bookwyrm/views/reading.py @@ -145,6 +145,7 @@ def create_readthrough(request): def load_date_in_user_tz_as_utc(date_str: str, user: models.User) -> datetime: + """ ensures that data is stored consistently in the UTC timezone """ user_tz = dateutil.tz.gettz(user.preferred_timezone) start_date = dateutil.parser.parse(date_str, ignoretz=True) return start_date.replace(tzinfo=user_tz).astimezone(dateutil.tz.UTC) diff --git a/bookwyrm/views/reports.py b/bookwyrm/views/reports.py index cb1a62ffb..07eb9b975 100644 --- a/bookwyrm/views/reports.py +++ b/bookwyrm/views/reports.py @@ -29,8 +29,10 @@ class Reports(View): resolved = request.GET.get("resolved") == "true" server = request.GET.get("server") if server: - server = get_object_or_404(models.FederatedServer, id=server) - filters["user__federated_server"] = server + filters["user__federated_server__server_name"] = server + username = request.GET.get("username") + if username: + filters["user__username__icontains"] = username filters["resolved"] = resolved data = { "resolved": resolved, @@ -72,12 +74,13 @@ class Report(View): @login_required @permission_required("bookwyrm_moderate_user") -def deactivate_user(_, report_id): +def suspend_user(_, user_id): """ mark an account as inactive """ - report = get_object_or_404(models.Report, id=report_id) - report.user.is_active = not report.user.is_active - report.user.save() - return redirect("settings-report", report.id) + user = get_object_or_404(models.User, id=user_id) + user.is_active = not user.is_active + # this isn't a full deletion, so we don't want to tell the world + user.save(broadcast=False) + return redirect("settings-user", user.id) @login_required @@ -98,8 +101,7 @@ def make_report(request): """ a user reports something """ form = forms.ReportForm(request.POST) if not form.is_valid(): - print(form.errors) - return redirect(request.headers.get("Referer", "/")) + raise ValueError(form.errors) form.save() return redirect(request.headers.get("Referer", "/")) diff --git a/bookwyrm/views/shelf.py b/bookwyrm/views/shelf.py index 41d1f1358..446bedba1 100644 --- a/bookwyrm/views/shelf.py +++ b/bookwyrm/views/shelf.py @@ -16,7 +16,7 @@ from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH from .helpers import is_api_request, get_edition, get_user_from_username -from .helpers import handle_reading_status, privacy_filter, object_visible_to_user +from .helpers import handle_reading_status, privacy_filter # pylint: disable= no-self-use @@ -30,11 +30,6 @@ class Shelf(View): except models.User.DoesNotExist: return HttpResponseNotFound() - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - shelves = privacy_filter(request.user, user.shelf_set) # get the shelf and make sure the logged in user should be able to see it @@ -43,7 +38,7 @@ class Shelf(View): shelf = user.shelf_set.get(identifier=shelf_identifier) except models.Shelf.DoesNotExist: return HttpResponseNotFound() - if not object_visible_to_user(request.user, shelf): + if not shelf.visible_to_user(request.user): return HttpResponseNotFound() # this is a constructed "all books" view, with a fake "shelf" obj else: @@ -61,7 +56,7 @@ class Shelf(View): return ActivitypubResponse(shelf.to_activity(**request.GET)) paginated = Paginator( - shelf.books.order_by("-updated_date").all(), + shelf.books.order_by("-updated_date"), PAGE_LENGTH, ) @@ -70,7 +65,7 @@ class Shelf(View): "is_self": is_self, "shelves": shelves.all(), "shelf": shelf, - "books": paginated.page(page), + "books": paginated.get_page(request.GET.get("page")), } return TemplateResponse(request, "user/shelf.html", data) diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py index aba804d8b..02db5971b 100644 --- a/bookwyrm/views/user.py +++ b/bookwyrm/views/user.py @@ -17,7 +17,7 @@ from bookwyrm import forms, models from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.settings import PAGE_LENGTH from .helpers import get_user_from_username, is_api_request -from .helpers import is_blocked, privacy_filter, object_visible_to_user +from .helpers import is_blocked, privacy_filter # pylint: disable= no-self-use @@ -40,11 +40,6 @@ class User(View): return ActivitypubResponse(user.to_activity()) # otherwise we're at a UI view - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - shelf_preview = [] # only show other shelves that should be visible @@ -80,14 +75,14 @@ class User(View): goal = models.AnnualGoal.objects.filter( user=user, year=timezone.now().year ).first() - if not object_visible_to_user(request.user, goal): + if goal and not goal.visible_to_user(request.user): goal = None data = { "user": user, "is_self": is_self, "shelves": shelf_preview, "shelf_count": shelves.count(), - "activities": paginated.page(page), + "activities": paginated.get_page(request.GET.get("page", 1)), "goal": goal, } diff --git a/bookwyrm/views/user_admin.py b/bookwyrm/views/user_admin.py index a8c155a16..4537abce3 100644 --- a/bookwyrm/views/user_admin.py +++ b/bookwyrm/views/user_admin.py @@ -6,7 +6,7 @@ from django.template.response import TemplateResponse from django.utils.decorators import method_decorator from django.views import View -from bookwyrm import models +from bookwyrm import forms, models from bookwyrm.settings import PAGE_LENGTH @@ -16,23 +16,22 @@ from bookwyrm.settings import PAGE_LENGTH permission_required("bookwyrm.moderate_users", raise_exception=True), name="dispatch", ) -class UserAdmin(View): +class UserAdminList(View): """ admin view of users on this server """ def get(self, request): """ list of users """ - try: - page = int(request.GET.get("page", 1)) - except ValueError: - page = 1 - filters = {} server = request.GET.get("server") if server: - server = get_object_or_404(models.FederatedServer, id=server) + server = models.FederatedServer.objects.filter(server_name=server).first() filters["federated_server"] = server + filters["federated_server__isnull"] = False + username = request.GET.get("username") + if username: + filters["username__icontains"] = username - users = models.User.objects.filter(**filters).all() + users = models.User.objects.filter(**filters) sort = request.GET.get("sort", "-created_date") sort_fields = [ @@ -46,5 +45,33 @@ class UserAdmin(View): users = users.order_by(sort) paginated = Paginator(users, PAGE_LENGTH) - data = {"users": paginated.page(page), "sort": sort, "server": server} - return TemplateResponse(request, "settings/user_admin.html", data) + data = { + "users": paginated.get_page(request.GET.get("page")), + "sort": sort, + "server": server, + } + return TemplateResponse(request, "user_admin/user_admin.html", data) + + +@method_decorator(login_required, name="dispatch") +@method_decorator( + permission_required("bookwyrm.moderate_users", raise_exception=True), + name="dispatch", +) +class UserAdmin(View): + """ moderate an individual user """ + + def get(self, request, user): + """ user view """ + user = get_object_or_404(models.User, id=user) + data = {"user": user, "group_form": forms.UserGroupForm()} + return TemplateResponse(request, "user_admin/user.html", data) + + def post(self, request, user): + """ update user group """ + user = get_object_or_404(models.User, id=user) + form = forms.UserGroupForm(request.POST, instance=user) + if form.is_valid(): + form.save() + data = {"user": user, "group_form": form} + return TemplateResponse(request, "user_admin/user.html", data) diff --git a/celerywyrm/settings.py b/celerywyrm/settings.py index 7591163b1..cd5b00ba4 100644 --- a/celerywyrm/settings.py +++ b/celerywyrm/settings.py @@ -20,7 +20,8 @@ EMAIL_HOST = env("EMAIL_HOST") EMAIL_PORT = env("EMAIL_PORT") EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = env("EMAIL_USE_TLS") +EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS") +EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -148,7 +149,7 @@ USE_TZ = True # Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/3.0/howto/static-files/ +# https://docs.djangoproject.com/en/3.1/howto/static-files/ STATIC_URL = "/static/" STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static")) diff --git a/certbot.sh b/certbot.sh new file mode 100644 index 000000000..6d2c3cd90 --- /dev/null +++ b/certbot.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +source .env; + +if [ "$CERTBOT_INIT" = "true" ] +then + certonly \ + --webroot \ + --webroot-path=/var/www/certbot \ + --email ${EMAIL} \ + --agree-tos \ + --no-eff-email \ + -d ${DOMAIN} \ + -d www.${DOMAIN} +else + renew \ + --webroot \ + --webroot-path \ + /var/www/certbot +fi diff --git a/docker-compose.yml b/docker-compose.yml index 3ee9037f9..9324b224b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,8 @@ services: - pgdata:/var/lib/postgresql/data networks: - main + ports: + - 5432:5432 web: build: . env_file: .env @@ -38,20 +40,26 @@ services: - 8000:8000 redis_activity: image: redis + command: ["redis-server", "--appendonly", "yes"] env_file: .env ports: - 6378:6378 networks: - main restart: on-failure + volumes: + - redis_activity_data:/data redis_broker: image: redis + command: ["redis-server", "--appendonly", "yes"] env_file: .env ports: - 6379:6379 networks: - main restart: on-failure + volumes: + - redis_broker_data:/data celery_worker: env_file: .env build: . @@ -84,5 +92,7 @@ volumes: pgdata: static_volume: media_volume: + redis_broker_data: + redis_activity_data: networks: main: diff --git a/instances.md b/instances.md deleted file mode 100644 index 570328b51..000000000 --- a/instances.md +++ /dev/null @@ -1,5 +0,0 @@ - -| name | url | admin contact | open registration | -| :--- | :-- | :------------ | :---------------- | -| bookwyrm.social | http://bookwyrm.social/ | mousereeve@riseup.net / @tripofmice@friend.camp | ❌ | -| wyrms.de | https://wyrms.de/ | wyrms@tofuwabo.hu / @tofuwabohu@subversive.zone | ❌ | diff --git a/locale/es/LC_MESSAGES/django.mo b/locale/es/LC_MESSAGES/django.mo index ea8eac7ee..d42d83945 100644 Binary files a/locale/es/LC_MESSAGES/django.mo and b/locale/es/LC_MESSAGES/django.mo differ diff --git a/locale/es/LC_MESSAGES/django.po b/locale/es/LC_MESSAGES/django.po index 64921d5e5..159e806ab 100644 --- a/locale/es/LC_MESSAGES/django.po +++ b/locale/es/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-01 13:14-0700\n" +"POT-Creation-Date: 2021-04-11 01:59+0000\n" "PO-Revision-Date: 2021-03-19 11:49+0800\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -49,6 +49,34 @@ msgstr "%(count)d usos" msgid "Unlimited" msgstr "Sin límite" +#: bookwyrm/forms.py:289 +msgid "List Order" +msgstr "Orden de la lista" + +#: bookwyrm/forms.py:290 +#| msgid "Title" +msgid "Book Title" +msgstr "Título" + +#: bookwyrm/forms.py:291 bookwyrm/templates/snippets/create_status_form.html:29 +#: bookwyrm/templates/user/shelf.html:81 +msgid "Rating" +msgstr "Calificación" + +#: bookwyrm/forms.py:293 bookwyrm/templates/lists/list.html:105 +msgid "Sort By" +msgstr "Ordenar por" + +#: bookwyrm/forms.py:297 +#| msgid "Started reading" +msgid "Ascending" +msgstr "Ascendente" + +#: bookwyrm/forms.py:298 +#| msgid "Started reading" +msgid "Descending" +msgstr "Descendente" + #: bookwyrm/models/fields.py:24 #, python-format msgid "%(value)s is not a valid remote_id" @@ -59,7 +87,7 @@ msgstr "%(value)s no es un remote_id válido" msgid "%(value)s is not a valid username" msgstr "%(value)s no es un usuario válido" -#: bookwyrm/models/fields.py:165 bookwyrm/templates/layout.html:157 +#: bookwyrm/models/fields.py:165 bookwyrm/templates/layout.html:152 msgid "username" msgstr "nombre de usuario" @@ -146,12 +174,12 @@ msgid "ISBN:" msgstr "ISBN:" #: bookwyrm/templates/book/book.html:69 -#: bookwyrm/templates/book/edit_book.html:211 +#: bookwyrm/templates/book/edit_book.html:223 msgid "OCLC Number:" msgstr "Número OCLC:" #: bookwyrm/templates/book/book.html:76 -#: bookwyrm/templates/book/edit_book.html:215 +#: bookwyrm/templates/book/edit_book.html:227 msgid "ASIN:" msgstr "ASIN:" @@ -171,17 +199,17 @@ msgid "Add Description" msgstr "Agregar descripción" #: bookwyrm/templates/book/book.html:107 -#: bookwyrm/templates/book/edit_book.html:101 +#: bookwyrm/templates/book/edit_book.html:107 #: bookwyrm/templates/lists/form.html:12 msgid "Description:" msgstr "Descripción:" #: bookwyrm/templates/book/book.html:111 -#: bookwyrm/templates/book/edit_book.html:225 +#: bookwyrm/templates/book/edit_book.html:237 #: bookwyrm/templates/edit_author.html:78 bookwyrm/templates/lists/form.html:42 #: bookwyrm/templates/preferences/edit_user.html:70 #: bookwyrm/templates/settings/site.html:93 -#: bookwyrm/templates/snippets/readthrough.html:65 +#: bookwyrm/templates/snippets/readthrough.html:75 #: bookwyrm/templates/snippets/shelve_button/finish_reading_modal.html:42 #: bookwyrm/templates/snippets/shelve_button/progress_update_modal.html:42 #: bookwyrm/templates/snippets/shelve_button/start_reading_modal.html:34 @@ -190,12 +218,12 @@ msgstr "Guardar" #: bookwyrm/templates/book/book.html:112 bookwyrm/templates/book/book.html:161 #: bookwyrm/templates/book/cover_modal.html:32 -#: bookwyrm/templates/book/edit_book.html:226 +#: bookwyrm/templates/book/edit_book.html:238 #: bookwyrm/templates/edit_author.html:79 #: bookwyrm/templates/moderation/report_modal.html:32 #: bookwyrm/templates/snippets/delete_readthrough_modal.html:17 #: bookwyrm/templates/snippets/goal_form.html:32 -#: bookwyrm/templates/snippets/readthrough.html:66 +#: bookwyrm/templates/snippets/readthrough.html:76 #: bookwyrm/templates/snippets/shelve_button/finish_reading_modal.html:43 #: bookwyrm/templates/snippets/shelve_button/progress_update_modal.html:43 #: bookwyrm/templates/snippets/shelve_button/start_reading_modal.html:35 @@ -257,7 +285,7 @@ msgstr "Irse a lista" #: bookwyrm/templates/book/book.html:223 #: bookwyrm/templates/book/cover_modal.html:31 -#: bookwyrm/templates/lists/list.html:90 +#: bookwyrm/templates/lists/list.html:156 msgid "Add" msgstr "Agregar" @@ -266,14 +294,14 @@ msgid "rated it" msgstr "lo calificó con" #: bookwyrm/templates/book/cover_modal.html:17 -#: bookwyrm/templates/book/edit_book.html:163 +#: bookwyrm/templates/book/edit_book.html:175 #, fuzzy #| msgid "Add cover" msgid "Upload cover:" msgstr "Agregar portada" #: bookwyrm/templates/book/cover_modal.html:23 -#: bookwyrm/templates/book/edit_book.html:169 +#: bookwyrm/templates/book/edit_book.html:181 msgid "Load cover from url:" msgstr "" @@ -353,93 +381,93 @@ msgstr "Volver" msgid "Metadata" msgstr "Metadatos" -#: bookwyrm/templates/book/edit_book.html:91 +#: bookwyrm/templates/book/edit_book.html:92 msgid "Title:" msgstr "Título:" -#: bookwyrm/templates/book/edit_book.html:96 +#: bookwyrm/templates/book/edit_book.html:100 msgid "Subtitle:" msgstr "Subtítulo:" -#: bookwyrm/templates/book/edit_book.html:106 +#: bookwyrm/templates/book/edit_book.html:112 msgid "Series:" msgstr "Serie:" -#: bookwyrm/templates/book/edit_book.html:111 +#: bookwyrm/templates/book/edit_book.html:117 msgid "Series number:" msgstr "Número de serie:" -#: bookwyrm/templates/book/edit_book.html:117 +#: bookwyrm/templates/book/edit_book.html:123 #, fuzzy #| msgid "Published" msgid "Publisher:" msgstr "Publicado" -#: bookwyrm/templates/book/edit_book.html:119 +#: bookwyrm/templates/book/edit_book.html:125 msgid "Separate multiple publishers with commas." msgstr "" -#: bookwyrm/templates/book/edit_book.html:125 +#: bookwyrm/templates/book/edit_book.html:132 msgid "First published date:" msgstr "Fecha de primera publicación:" -#: bookwyrm/templates/book/edit_book.html:130 +#: bookwyrm/templates/book/edit_book.html:140 msgid "Published date:" msgstr "Fecha de publicación:" -#: bookwyrm/templates/book/edit_book.html:137 +#: bookwyrm/templates/book/edit_book.html:149 #, fuzzy #| msgid "Author" msgid "Authors" msgstr "Autor/Autora" -#: bookwyrm/templates/book/edit_book.html:143 +#: bookwyrm/templates/book/edit_book.html:155 #, fuzzy, python-format #| msgid "Added by %(username)s" msgid "Remove %(name)s" msgstr "Agregado por %(username)s" -#: bookwyrm/templates/book/edit_book.html:148 +#: bookwyrm/templates/book/edit_book.html:160 #, fuzzy #| msgid "Edit Author" msgid "Add Authors:" msgstr "Editar Autor/Autora" -#: bookwyrm/templates/book/edit_book.html:149 +#: bookwyrm/templates/book/edit_book.html:161 msgid "John Doe, Jane Smith" msgstr "" -#: bookwyrm/templates/book/edit_book.html:155 +#: bookwyrm/templates/book/edit_book.html:167 #: bookwyrm/templates/user/shelf.html:75 msgid "Cover" msgstr "Portada:" -#: bookwyrm/templates/book/edit_book.html:182 +#: bookwyrm/templates/book/edit_book.html:194 msgid "Physical Properties" msgstr "Propiedades físicas:" -#: bookwyrm/templates/book/edit_book.html:183 +#: bookwyrm/templates/book/edit_book.html:195 #: bookwyrm/templates/book/format_filter.html:5 msgid "Format:" msgstr "Formato:" -#: bookwyrm/templates/book/edit_book.html:191 +#: bookwyrm/templates/book/edit_book.html:203 msgid "Pages:" msgstr "Páginas:" -#: bookwyrm/templates/book/edit_book.html:198 +#: bookwyrm/templates/book/edit_book.html:210 msgid "Book Identifiers" msgstr "Identificadores de libro" -#: bookwyrm/templates/book/edit_book.html:199 +#: bookwyrm/templates/book/edit_book.html:211 msgid "ISBN 13:" msgstr "ISBN 13:" -#: bookwyrm/templates/book/edit_book.html:203 +#: bookwyrm/templates/book/edit_book.html:215 msgid "ISBN 10:" msgstr "ISBN 10:" -#: bookwyrm/templates/book/edit_book.html:207 +#: bookwyrm/templates/book/edit_book.html:219 #: bookwyrm/templates/edit_author.html:59 msgid "Openlibrary key:" msgstr "Clave OpenLibrary:" @@ -497,12 +525,18 @@ msgstr "" #: bookwyrm/templates/components/inline_form.html:8 #: bookwyrm/templates/components/modal.html:11 -#: bookwyrm/templates/feed/feed_layout.html:57 +#: bookwyrm/templates/feed/feed_layout.html:70 #: bookwyrm/templates/get_started/layout.html:19 #: bookwyrm/templates/get_started/layout.html:52 msgid "Close" msgstr "Cerrar" +#: bookwyrm/templates/compose.html:5 bookwyrm/templates/compose.html:8 +#, fuzzy +#| msgid "Boost status" +msgid "Compose status" +msgstr "Status de respaldo" + #: bookwyrm/templates/directory/community_filter.html:5 #, fuzzy #| msgid "Comment" @@ -523,7 +557,7 @@ msgstr "Federalizado" #: bookwyrm/templates/directory/directory.html:6 #: bookwyrm/templates/directory/directory.html:11 -#: bookwyrm/templates/layout.html:97 +#: bookwyrm/templates/layout.html:92 msgid "Directory" msgstr "" @@ -844,23 +878,23 @@ msgstr "Tus libros" msgid "There are no books here right now! Try searching for a book to get started" msgstr "¡No hay ningún libro aqui ahorita! Busca a un libro para empezar" -#: bookwyrm/templates/feed/feed_layout.html:23 +#: bookwyrm/templates/feed/feed_layout.html:24 #: bookwyrm/templates/user/shelf.html:28 msgid "To Read" msgstr "Para leer" -#: bookwyrm/templates/feed/feed_layout.html:24 +#: bookwyrm/templates/feed/feed_layout.html:25 #: bookwyrm/templates/user/shelf.html:28 msgid "Currently Reading" msgstr "Leyendo actualmente" -#: bookwyrm/templates/feed/feed_layout.html:25 +#: bookwyrm/templates/feed/feed_layout.html:26 #: bookwyrm/templates/snippets/shelve_button/shelve_button_options.html:11 #: bookwyrm/templates/user/shelf.html:28 msgid "Read" msgstr "Leer" -#: bookwyrm/templates/feed/feed_layout.html:74 bookwyrm/templates/goal.html:26 +#: bookwyrm/templates/feed/feed_layout.html:88 bookwyrm/templates/goal.html:26 #: bookwyrm/templates/snippets/goal_card.html:6 #, python-format msgid "%(year)s Reading Goal" @@ -893,7 +927,7 @@ msgid "What are you reading?" msgstr "Lectura se empezó" #: bookwyrm/templates/get_started/books.html:9 -#: bookwyrm/templates/lists/list.html:58 +#: bookwyrm/templates/lists/list.html:124 msgid "Search for a book" msgstr "Buscar libros" @@ -914,7 +948,7 @@ msgstr "" #: bookwyrm/templates/get_started/users.html:18 #: bookwyrm/templates/get_started/users.html:19 #: bookwyrm/templates/layout.html:37 bookwyrm/templates/layout.html:38 -#: bookwyrm/templates/lists/list.html:62 +#: bookwyrm/templates/lists/list.html:128 msgid "Search" msgstr "Buscar" @@ -931,7 +965,7 @@ msgid "Popular on %(site_name)s" msgstr "Sobre %(site_name)s" #: bookwyrm/templates/get_started/books.html:51 -#: bookwyrm/templates/lists/list.html:75 +#: bookwyrm/templates/lists/list.html:141 msgid "No books found" msgstr "No se encontró ningún libro" @@ -1055,7 +1089,7 @@ msgid "%(username)s's %(year)s Books" msgstr "Los libros de %(username)s para %(year)s" #: bookwyrm/templates/import.html:5 bookwyrm/templates/import.html:9 -#: bookwyrm/templates/layout.html:102 +#: bookwyrm/templates/layout.html:97 msgid "Import Books" msgstr "Importar libros" @@ -1196,16 +1230,11 @@ msgstr "Menú de navigación central" msgid "Feed" msgstr "Actividad" -#: bookwyrm/templates/layout.html:92 -#: bookwyrm/templates/preferences/preferences_layout.html:14 -msgid "Profile" -msgstr "Perfil" - -#: bookwyrm/templates/layout.html:107 +#: bookwyrm/templates/layout.html:102 msgid "Settings" msgstr "Configuración" -#: bookwyrm/templates/layout.html:116 +#: bookwyrm/templates/layout.html:111 #: bookwyrm/templates/settings/admin_layout.html:24 #: bookwyrm/templates/settings/manage_invite_requests.html:15 #: bookwyrm/templates/settings/manage_invites.html:3 @@ -1213,57 +1242,57 @@ msgstr "Configuración" msgid "Invites" msgstr "Invitaciones" -#: bookwyrm/templates/layout.html:123 +#: bookwyrm/templates/layout.html:118 msgid "Admin" msgstr "" -#: bookwyrm/templates/layout.html:130 +#: bookwyrm/templates/layout.html:125 msgid "Log out" msgstr "Cerrar sesión" -#: bookwyrm/templates/layout.html:138 bookwyrm/templates/layout.html:139 +#: bookwyrm/templates/layout.html:133 bookwyrm/templates/layout.html:134 #: bookwyrm/templates/notifications.html:6 #: bookwyrm/templates/notifications.html:10 msgid "Notifications" msgstr "Notificaciones" -#: bookwyrm/templates/layout.html:156 bookwyrm/templates/layout.html:160 +#: bookwyrm/templates/layout.html:151 bookwyrm/templates/layout.html:155 #: bookwyrm/templates/login.html:17 #: bookwyrm/templates/snippets/register_form.html:4 msgid "Username:" msgstr "Nombre de usuario:" -#: bookwyrm/templates/layout.html:161 +#: bookwyrm/templates/layout.html:156 msgid "password" msgstr "contraseña" -#: bookwyrm/templates/layout.html:162 bookwyrm/templates/login.html:36 +#: bookwyrm/templates/layout.html:157 bookwyrm/templates/login.html:36 msgid "Forgot your password?" msgstr "¿Olvidaste tu contraseña?" -#: bookwyrm/templates/layout.html:165 bookwyrm/templates/login.html:10 +#: bookwyrm/templates/layout.html:160 bookwyrm/templates/login.html:10 #: bookwyrm/templates/login.html:33 msgid "Log in" msgstr "Iniciar sesión" -#: bookwyrm/templates/layout.html:173 +#: bookwyrm/templates/layout.html:168 msgid "Join" msgstr "" -#: bookwyrm/templates/layout.html:196 +#: bookwyrm/templates/layout.html:191 msgid "About this server" msgstr "Sobre este servidor" -#: bookwyrm/templates/layout.html:200 +#: bookwyrm/templates/layout.html:195 msgid "Contact site admin" msgstr "Contactarse con administradores del sitio" -#: bookwyrm/templates/layout.html:207 +#: bookwyrm/templates/layout.html:202 #, python-format msgid "Support %(site_name)s on %(support_title)s" msgstr "" -#: bookwyrm/templates/layout.html:211 +#: bookwyrm/templates/layout.html:206 msgid "BookWyrm is open source software. You can contribute or report issues on GitHub." msgstr "BookWyrm es software de código abierto. Puedes contribuir o reportar problemas en GitHub." @@ -1342,45 +1371,74 @@ msgstr "Abierto" msgid "Anyone can add books to this list" msgstr "Cualquer usuario puede agregar libros a esta lista" -#: bookwyrm/templates/lists/list.html:17 +#: bookwyrm/templates/lists/list.html:19 +#: bookwyrm/templates/snippets/pagination.html:12 +msgid "Previous" +msgstr "Anterior" + +#: bookwyrm/templates/lists/list.html:22 +#: bookwyrm/templates/snippets/pagination.html:23 +msgid "Next" +msgstr "Siguiente" + +#: bookwyrm/templates/lists/list.html:58 msgid "This list is currently empty" msgstr "Esta lista está vacia" -#: bookwyrm/templates/lists/list.html:35 +#: bookwyrm/templates/lists/list.html:76 #, python-format msgid "Added by %(username)s" msgstr "Agregado por %(username)s" -#: bookwyrm/templates/lists/list.html:41 -#: bookwyrm/templates/snippets/shelf_selector.html:28 +#: bookwyrm/templates/lists/list.html:82 +#: bookwyrm/templates/snippets/shelf_selector.html:26 msgid "Remove" msgstr "Quitar" -#: bookwyrm/templates/lists/list.html:54 +#: bookwyrm/templates/lists/list.html:90 +msgid "Set list position" +msgstr "Apunta indice" + +#: bookwyrm/templates/lists/list.html:103 +#: bookwyrm/templates/lists/list.html:115 +msgid "Sort List" +msgstr "Ordena la lista" + +#: bookwyrm/templates/lists/list.html:109 +#, fuzzy +#| msgid "List curation:" +msgid "Direction" +msgstr "Enumerar lista de comisariado:" + +#: bookwyrm/templates/lists/list.html:120 msgid "Add Books" msgstr "Agregar libros" -#: bookwyrm/templates/lists/list.html:54 +#: bookwyrm/templates/lists/list.html:120 msgid "Suggest Books" msgstr "Sugerir libros" -#: bookwyrm/templates/lists/list.html:63 +#: bookwyrm/templates/lists/list.html:129 msgid "search" msgstr "buscar" -#: bookwyrm/templates/lists/list.html:69 +#: bookwyrm/templates/lists/list.html:135 msgid "Clear search" msgstr "Borrar búsqueda" -#: bookwyrm/templates/lists/list.html:74 +#: bookwyrm/templates/lists/list.html:140 #, python-format msgid "No books found matching the query \"%(query)s\"" msgstr "No se encontró ningún libro correspondiente a la búsqueda: \"%(query)s\"" -#: bookwyrm/templates/lists/list.html:90 +#: bookwyrm/templates/lists/list.html:156 msgid "Suggest" msgstr "Sugerir" +#: bookwyrm/templates/lists/lists.html:14 bookwyrm/templates/user/lists.html:9 +msgid "Your Lists" +msgstr "Tus listas" + #: bookwyrm/templates/login.html:4 msgid "Login" msgstr "Iniciar sesión" @@ -1422,7 +1480,7 @@ msgid "View user profile" msgstr "Perfil de usuario" #: bookwyrm/templates/moderation/report.html:22 -#: bookwyrm/templates/snippets/status/status_options.html:25 +#: bookwyrm/templates/snippets/status/status_options.html:35 #: bookwyrm/templates/snippets/user_options.html:13 msgid "Send direct message" msgstr "Enviar mensaje directo" @@ -1440,8 +1498,8 @@ msgid "Moderator Comments" msgstr "" #: bookwyrm/templates/moderation/report.html:54 -#: bookwyrm/templates/snippets/create_status.html:12 -#: bookwyrm/templates/snippets/create_status_form.html:52 +#: bookwyrm/templates/snippets/create_status.html:28 +#: bookwyrm/templates/snippets/create_status_form.html:44 msgid "Comment" msgstr "Comentario" @@ -1698,6 +1756,10 @@ msgstr "Huso horario preferido" msgid "Account" msgstr "Cuenta" +#: bookwyrm/templates/preferences/preferences_layout.html:14 +msgid "Profile" +msgstr "Perfil" + #: bookwyrm/templates/preferences/preferences_layout.html:20 msgid "Relationships" msgstr "Relaciones" @@ -1854,7 +1916,8 @@ msgid "Software" msgstr "Software" #: bookwyrm/templates/settings/federation.html:24 -#: bookwyrm/templates/settings/manage_invite_requests.html:33 +#: bookwyrm/templates/settings/manage_invite_requests.html:44 +#: bookwyrm/templates/settings/status_filter.html:5 #: bookwyrm/templates/settings/user_admin.html:32 msgid "Status" msgstr "Status" @@ -1872,61 +1935,72 @@ msgstr "Invitaciones" msgid "Ignored Invite Requests" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:31 -msgid "Date" -msgstr "" +#: bookwyrm/templates/settings/manage_invite_requests.html:35 +#, fuzzy +#| msgid "Federated" +msgid "Date requested" +msgstr "Federalizado" -#: bookwyrm/templates/settings/manage_invite_requests.html:32 +#: bookwyrm/templates/settings/manage_invite_requests.html:39 +#, fuzzy +#| msgid "Accept" +msgid "Date accepted" +msgstr "Aceptar" + +#: bookwyrm/templates/settings/manage_invite_requests.html:42 msgid "Email" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:34 +#: bookwyrm/templates/settings/manage_invite_requests.html:47 #, fuzzy #| msgid "Notifications" msgid "Action" msgstr "Notificaciones" -#: bookwyrm/templates/settings/manage_invite_requests.html:37 +#: bookwyrm/templates/settings/manage_invite_requests.html:50 #, fuzzy #| msgid "Follow Requests" msgid "No requests" msgstr "Solicitudes de seguidor" -#: bookwyrm/templates/settings/manage_invite_requests.html:45 +#: bookwyrm/templates/settings/manage_invite_requests.html:59 +#: bookwyrm/templates/settings/status_filter.html:16 #, fuzzy #| msgid "Accept" msgid "Accepted" msgstr "Aceptar" -#: bookwyrm/templates/settings/manage_invite_requests.html:47 +#: bookwyrm/templates/settings/manage_invite_requests.html:61 +#: bookwyrm/templates/settings/status_filter.html:12 msgid "Sent" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:49 +#: bookwyrm/templates/settings/manage_invite_requests.html:63 +#: bookwyrm/templates/settings/status_filter.html:8 msgid "Requested" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:57 +#: bookwyrm/templates/settings/manage_invite_requests.html:73 msgid "Send invite" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:59 +#: bookwyrm/templates/settings/manage_invite_requests.html:75 msgid "Re-send invite" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:70 +#: bookwyrm/templates/settings/manage_invite_requests.html:95 msgid "Ignore" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:72 -msgid "Un-gnore" +#: bookwyrm/templates/settings/manage_invite_requests.html:97 +msgid "Un-ignore" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:83 +#: bookwyrm/templates/settings/manage_invite_requests.html:108 msgid "Back to pending requests" msgstr "" -#: bookwyrm/templates/settings/manage_invite_requests.html:85 +#: bookwyrm/templates/settings/manage_invite_requests.html:110 msgid "View ignored requests" msgstr "" @@ -2080,8 +2154,8 @@ msgstr "%(title)s por " #: bookwyrm/templates/snippets/boost_button.html:8 #: bookwyrm/templates/snippets/boost_button.html:9 -#: bookwyrm/templates/snippets/status/status_body.html:51 #: bookwyrm/templates/snippets/status/status_body.html:52 +#: bookwyrm/templates/snippets/status/status_body.html:53 msgid "Boost status" msgstr "Status de respaldo" @@ -2094,15 +2168,15 @@ msgstr "Status de des-respaldo" msgid "Spoiler alert:" msgstr "Alerta de spoiler:" -#: bookwyrm/templates/snippets/content_warning_field.html:4 +#: bookwyrm/templates/snippets/content_warning_field.html:10 msgid "Spoilers ahead!" msgstr "¡Advertencia, ya vienen spoilers!" -#: bookwyrm/templates/snippets/create_status.html:9 +#: bookwyrm/templates/snippets/create_status.html:17 msgid "Review" msgstr "Reseña" -#: bookwyrm/templates/snippets/create_status.html:15 +#: bookwyrm/templates/snippets/create_status.html:39 msgid "Quote" msgstr "Cita" @@ -2124,52 +2198,41 @@ msgstr "Cita" msgid "Review:" msgstr "Reseña" -#: bookwyrm/templates/snippets/create_status_form.html:29 -#: bookwyrm/templates/user/shelf.html:81 -msgid "Rating" -msgstr "Calificación" - -#: bookwyrm/templates/snippets/create_status_form.html:31 -#: bookwyrm/templates/snippets/rate_action.html:14 -#: bookwyrm/templates/snippets/stars.html:3 -msgid "No rating" -msgstr "No calificación" - -#: bookwyrm/templates/snippets/create_status_form.html:64 +#: bookwyrm/templates/snippets/create_status_form.html:56 #: bookwyrm/templates/snippets/shelve_button/progress_update_modal.html:16 msgid "Progress:" msgstr "Progreso:" -#: bookwyrm/templates/snippets/create_status_form.html:71 +#: bookwyrm/templates/snippets/create_status_form.html:63 #: bookwyrm/templates/snippets/readthrough_form.html:22 #: bookwyrm/templates/snippets/shelve_button/progress_update_modal.html:30 msgid "pages" msgstr "páginas" -#: bookwyrm/templates/snippets/create_status_form.html:72 +#: bookwyrm/templates/snippets/create_status_form.html:64 #: bookwyrm/templates/snippets/readthrough_form.html:23 #: bookwyrm/templates/snippets/shelve_button/progress_update_modal.html:31 msgid "percent" msgstr "por ciento" -#: bookwyrm/templates/snippets/create_status_form.html:77 +#: bookwyrm/templates/snippets/create_status_form.html:69 #: bookwyrm/templates/snippets/shelve_button/progress_update_modal.html:36 #, python-format msgid "of %(pages)s pages" msgstr "de %(pages)s páginas" -#: bookwyrm/templates/snippets/create_status_form.html:89 +#: bookwyrm/templates/snippets/create_status_form.html:81 msgid "Include spoiler alert" msgstr "Incluir alerta de spoiler" -#: bookwyrm/templates/snippets/create_status_form.html:95 +#: bookwyrm/templates/snippets/create_status_form.html:88 #: bookwyrm/templates/snippets/privacy-icons.html:15 #: bookwyrm/templates/snippets/privacy-icons.html:16 #: bookwyrm/templates/snippets/privacy_select.html:19 msgid "Private" msgstr "Privada" -#: bookwyrm/templates/snippets/create_status_form.html:102 +#: bookwyrm/templates/snippets/create_status_form.html:99 msgid "Post" msgstr "Compartir" @@ -2184,13 +2247,14 @@ msgstr "Estás eliminando esta lectura y sus %(count)s actualizaciones de progre #: bookwyrm/templates/snippets/delete_readthrough_modal.html:15 #: bookwyrm/templates/snippets/follow_request_buttons.html:13 +#: venv/lib/python3.8/site-packages/django/forms/formsets.py:391 msgid "Delete" msgstr "Eliminar" #: bookwyrm/templates/snippets/fav_button.html:7 #: bookwyrm/templates/snippets/fav_button.html:8 -#: bookwyrm/templates/snippets/status/status_body.html:55 #: bookwyrm/templates/snippets/status/status_body.html:56 +#: bookwyrm/templates/snippets/status/status_body.html:57 msgid "Like status" msgstr "Me gusta status" @@ -2209,11 +2273,11 @@ msgstr "Mostrar menos" msgid "Hide filters" msgstr "" -#: bookwyrm/templates/snippets/filters_panel/filters_panel.html:19 +#: bookwyrm/templates/snippets/filters_panel/filters_panel.html:22 msgid "Apply filters" msgstr "" -#: bookwyrm/templates/snippets/filters_panel/filters_panel.html:23 +#: bookwyrm/templates/snippets/filters_panel/filters_panel.html:26 #, fuzzy #| msgid "Clear search" msgid "Clear filters" @@ -2237,6 +2301,19 @@ msgstr "Dejar de seguir" msgid "Accept" msgstr "Aceptar" +#: bookwyrm/templates/snippets/form_rate_stars.html:20 +#: bookwyrm/templates/snippets/stars.html:13 +msgid "No rating" +msgstr "No calificación" + +#: bookwyrm/templates/snippets/form_rate_stars.html:45 +#: bookwyrm/templates/snippets/stars.html:7 +#, python-format +msgid "%(rating)s star" +msgid_plural "%(rating)s stars" +msgstr[0] "" +msgstr[1] "" + #: bookwyrm/templates/snippets/generated_status/goal.html:1 #, python-format msgid "set a goal to read %(counter)s book in %(year)s" @@ -2311,13 +2388,17 @@ msgstr "Has leído %(read_count)s de %(goal_count)s libros< msgid "%(username)s has read %(read_count)s of %(goal_count)s books." msgstr "%(username)s ha leído %(read_count)s de %(goal_count)s libros." -#: bookwyrm/templates/snippets/pagination.html:7 -msgid "Previous" -msgstr "Anterior" +#: bookwyrm/templates/snippets/page_text.html:4 +#, fuzzy, python-format +#| msgid "of %(pages)s pages" +msgid "page %(page)s of %(total_pages)s" +msgstr "de %(pages)s páginas" -#: bookwyrm/templates/snippets/pagination.html:15 -msgid "Next" -msgstr "Siguiente" +#: bookwyrm/templates/snippets/page_text.html:6 +#, fuzzy, python-format +#| msgid "%(pages)s pages" +msgid "page %(page)s" +msgstr "%(pages)s páginas" #: bookwyrm/templates/snippets/privacy-icons.html:3 #: bookwyrm/templates/snippets/privacy-icons.html:4 @@ -2348,7 +2429,7 @@ msgstr "Seguidores" msgid "Leave a rating" msgstr "Da una calificación" -#: bookwyrm/templates/snippets/rate_action.html:29 +#: bookwyrm/templates/snippets/rate_action.html:19 msgid "Rate" msgstr "Calificar" @@ -2356,28 +2437,28 @@ msgstr "Calificar" msgid "Progress Updates:" msgstr "Actualizaciones de progreso:" -#: bookwyrm/templates/snippets/readthrough.html:12 +#: bookwyrm/templates/snippets/readthrough.html:14 msgid "finished" msgstr "terminado" -#: bookwyrm/templates/snippets/readthrough.html:15 +#: bookwyrm/templates/snippets/readthrough.html:25 msgid "Show all updates" msgstr "Mostrar todas las actualizaciones" -#: bookwyrm/templates/snippets/readthrough.html:31 +#: bookwyrm/templates/snippets/readthrough.html:41 msgid "Delete this progress update" msgstr "Eliminar esta actualización de progreso" -#: bookwyrm/templates/snippets/readthrough.html:41 +#: bookwyrm/templates/snippets/readthrough.html:51 msgid "started" msgstr "empezado" -#: bookwyrm/templates/snippets/readthrough.html:47 -#: bookwyrm/templates/snippets/readthrough.html:61 +#: bookwyrm/templates/snippets/readthrough.html:57 +#: bookwyrm/templates/snippets/readthrough.html:71 msgid "Edit read dates" msgstr "Editar fechas de lectura" -#: bookwyrm/templates/snippets/readthrough.html:51 +#: bookwyrm/templates/snippets/readthrough.html:61 msgid "Delete these read dates" msgstr "Eliminar estas fechas de lectura" @@ -2501,9 +2582,9 @@ msgstr "respaldó" msgid "Delete status" msgstr "Eliminar status" -#: bookwyrm/templates/snippets/status/status_body.html:34 -#: bookwyrm/templates/snippets/status/status_body.html:47 +#: bookwyrm/templates/snippets/status/status_body.html:35 #: bookwyrm/templates/snippets/status/status_body.html:48 +#: bookwyrm/templates/snippets/status/status_body.html:49 msgid "Reply" msgstr "Respuesta" @@ -2550,6 +2631,12 @@ msgstr "respondió a tu tag or including the “Referrer-Policy: no-referrer” header, please remove them. The CSRF protection requires the “Referer” header to do strict referer checking. If you’re concerned about privacy, use alternatives like for links to third-party sites." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/csrf.py:132 +msgid "You are seeing this message because this site requires a CSRF cookie when submitting forms. This cookie is required for security reasons, to ensure that your browser is not being hijacked by third parties." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/csrf.py:137 +msgid "If you have configured your browser to disable cookies, please re-enable them, at least for this site, or for “same-origin” requests." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/csrf.py:142 +msgid "More information is available with DEBUG=True." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:41 +msgid "No year specified" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:61 +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:111 +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:208 +msgid "Date out of range" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:90 +msgid "No month specified" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:142 +msgid "No day specified" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:188 +msgid "No week specified" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:338 +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:367 +#, python-format +msgid "No %(verbose_name_plural)s available" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:589 +#, python-format +msgid "Future %(verbose_name_plural)s not available because %(class_name)s.allow_future is False." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/dates.py:623 +#, python-format +msgid "Invalid date string “%(datestr)s” given format “%(format)s”" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/detail.py:54 +#, fuzzy, python-format +#| msgid "No books found matching the query \"%(query)s\"" +msgid "No %(verbose_name)s found matching the query" +msgstr "No se encontró ningún libro correspondiente a la búsqueda: \"%(query)s\"" + +#: venv/lib/python3.8/site-packages/django/views/generic/list.py:67 +msgid "Page is not “last”, nor can it be converted to an int." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/list.py:72 +#, python-format +msgid "Invalid page (%(page_number)s): %(message)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/generic/list.py:154 +#, python-format +msgid "Empty list and “%(class_name)s.allow_empty” is False." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/static.py:40 +msgid "Directory indexes are not allowed here." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/static.py:42 +#, fuzzy, python-format +#| msgid "%(value)s is not a valid username" +msgid "“%(path)s” does not exist" +msgstr "%(value)s no es un usuario válido" + +#: venv/lib/python3.8/site-packages/django/views/static.py:80 +#, python-format +msgid "Index of %(directory)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:7 +msgid "Django: the Web framework for perfectionists with deadlines." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:346 +#, python-format +msgid "View release notes for Django %(version)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:368 +msgid "The install worked successfully! Congratulations!" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:369 +#, python-format +msgid "You are seeing this page because DEBUG=True is in your settings file and you have not configured any URLs." +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:384 +msgid "Django Documentation" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:385 +msgid "Topics, references, & how-to’s" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:396 +msgid "Tutorial: A Polling App" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:397 +msgid "Get started with Django" +msgstr "" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:408 +#, fuzzy +#| msgid "Comment" +msgid "Django Community" +msgstr "Comentario" + +#: venv/lib/python3.8/site-packages/django/views/templates/default_urlconf.html:409 +msgid "Connect, get help, or contribute" +msgstr "" + +#: venv/lib/python3.8/site-packages/kombu/transport/qpid.py:1301 +#, python-format +msgid "Attempting to connect to qpid with SASL mechanism %s" +msgstr "" + +#: venv/lib/python3.8/site-packages/kombu/transport/qpid.py:1306 +#, python-format +msgid "Connected to qpid with SASL mechanism %s" +msgstr "" + +#: venv/lib/python3.8/site-packages/kombu/transport/qpid.py:1324 +#, python-format +msgid "Unable to connect to qpid with SASL mechanism %s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:371 +msgid "1 second ago" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:377 +msgid "1 minute ago" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:382 +msgid "1 hour ago" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:385 +#, python-format +msgid "%(time)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:387 +msgid "yesterday" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:387 +#, python-format +msgid "yesterday at %(time)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:389 +#, python-format +msgid "%(weekday)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:389 +#, python-format +msgid "%(weekday)s at %(time)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:392 +#: venv/lib/python3.8/site-packages/tornado/locale.py:445 +#, python-format +msgid "%(month_name)s %(day)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:394 +#, python-format +msgid "%(month_name)s %(day)s at %(time)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:399 +#, python-format +msgid "%(month_name)s %(day)s, %(year)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:401 +#, python-format +msgid "%(month_name)s %(day)s, %(year)s at %(time)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:439 +#, python-format +msgid "%(weekday)s, %(month_name)s %(day)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/locale.py:462 +#, python-format +msgid "%(commas)s and %(last)s" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/test/locale_test.py:68 +msgctxt "law" +msgid "right" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/test/locale_test.py:69 +msgctxt "good" +msgid "right" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/test/locale_test.py:71 +#: venv/lib/python3.8/site-packages/tornado/test/locale_test.py:74 +msgctxt "organization" +msgid "club" +msgstr "" + +#: venv/lib/python3.8/site-packages/tornado/test/locale_test.py:76 +#: venv/lib/python3.8/site-packages/tornado/test/locale_test.py:77 +msgctxt "stick" +msgid "club" +msgstr "" + #, fuzzy #~| msgid "Started" #~ msgid "Getting Started" #~ msgstr "Empezado" -#, fuzzy, python-format +#, fuzzy #~| msgid "No users found for \"%(query)s\"" #~ msgid "No users were found for \"%(query)s\"" #~ msgstr "No se encontró ningún usuario correspondiente a \"%(query)s\"" @@ -2757,7 +4188,6 @@ msgstr "" #~ msgid "Your lists" #~ msgstr "Tus listas" -#, python-format #~ msgid "See all %(size)s lists" #~ msgstr "Ver las %(size)s listas" @@ -2782,148 +4212,18 @@ msgstr "" #~ msgid "Your Shelves" #~ msgstr "Tus estantes" -#, python-format #~ msgid "%(username)s: Shelves" #~ msgstr "%(username)s: Estantes" #~ msgid "Shelves" #~ msgstr "Estantes" -#, python-format #~ msgid "See all %(shelf_count)s shelves" #~ msgstr "Ver los %(shelf_count)s estantes" #~ msgid "Send follow request" #~ msgstr "Envia solicitud de seguidor" -#, fuzzy -#~| msgid "All messages" -#~ msgid "Messages" -#~ msgstr "Todos los mensajes" - -#, fuzzy -#~| msgid "Email address:" -#~ msgid "Enter a valid email address." -#~ msgstr "Dirección de correo electrónico:" - -#, fuzzy -#~| msgid "Series number:" -#~ msgid "Enter a number." -#~ msgstr "Número de serie:" - -#, fuzzy -#~| msgid "%(value)s is not a valid remote_id" -#~ msgid "Value %(value)r is not a valid choice." -#~ msgstr "%(value)s no es un remote_id válido" - -#, fuzzy -#~| msgid "Series number:" -#~ msgid "Decimal number" -#~ msgstr "Número de serie:" - -#, fuzzy -#~| msgid "List curation:" -#~ msgid "Duration" -#~ msgstr "Enumerar lista de comisariado:" - -#, fuzzy -#~| msgid "Email address:" -#~ msgid "Email address" -#~ msgstr "Dirección de correo electrónico:" - -#, fuzzy -#~| msgid "Email address:" -#~ msgid "IPv4 address" -#~ msgstr "Dirección de correo electrónico:" - -#, fuzzy -#~| msgid "Email address:" -#~ msgid "IP address" -#~ msgstr "Dirección de correo electrónico:" - -#, fuzzy -#~| msgid "No active invites" -#~ msgid "Positive integer" -#~ msgstr "No invitaciónes activas" - -#, fuzzy -#~| msgid "%(value)s is not a valid username" -#~ msgid "“%(value)s” is not a valid UUID." -#~ msgstr "%(value)s no es un usuario válido" - -#, fuzzy -#~| msgid "Images" -#~ msgid "Image" -#~ msgstr "Imagenes" - -#, fuzzy -#~| msgid "Relationships" -#~ msgid "One-to-one relationship" -#~ msgstr "Relaciones" - -#, fuzzy -#~| msgid "This shelf is empty." -#~ msgid "This field is required." -#~ msgstr "Este estante está vacio." - -#, fuzzy -#~| msgid "This shelf is empty." -#~ msgid "The submitted file is empty." -#~ msgstr "Este estante está vacio." - -#, fuzzy -#~| msgid "%(value)s is not a valid username" -#~ msgid "“%(pk)s” is not a valid value." -#~ msgstr "%(value)s no es un usuario válido" - -#, fuzzy -#~| msgid "Currently Reading" -#~ msgid "Currently" -#~ msgstr "Leyendo actualmente" - -#, fuzzy -#~| msgid "Change shelf" -#~ msgid "Change" -#~ msgstr "Cambiar estante" - -#, fuzzy -#~| msgid "Status" -#~ msgid "Sat" -#~ msgstr "Status" - -#, fuzzy -#~| msgid "Search" -#~ msgid "March" -#~ msgstr "Buscar" - -#, fuzzy -#~| msgid "Series number:" -#~ msgid "September" -#~ msgstr "Número de serie:" - -#, fuzzy -#~| msgid "Search" -#~ msgctxt "abbrev. month" -#~ msgid "March" -#~ msgstr "Buscar" - -#, fuzzy -#~| msgid "Search" -#~ msgctxt "alt. month" -#~ msgid "March" -#~ msgstr "Buscar" - -#, fuzzy -#~| msgid "Series number:" -#~ msgctxt "alt. month" -#~ msgid "September" -#~ msgstr "Número de serie:" - -#, fuzzy -#~| msgid "No books found matching the query \"%(query)s\"" -#~ msgid "No %(verbose_name)s found matching the query" -#~ msgstr "No se encontró ningún libro correspondiente a la búsqueda: \"%(query)s\"" - #~ msgid "Announcements" #~ msgstr "Anuncios" diff --git a/locale/fr_FR/LC_MESSAGES/django.po b/locale/fr_FR/LC_MESSAGES/django.po index c765cb896..c05a9f7a8 100644 --- a/locale/fr_FR/LC_MESSAGES/django.po +++ b/locale/fr_FR/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-05 07:29+0000\n" +"POT-Creation-Date: 2021-04-09 21:53+0000\n" "PO-Revision-Date: 2021-04-05 12:44+0100\n" "Last-Translator: Fabien Basmaison \n" "Language-Team: Mouse Reeve \n" @@ -44,7 +44,6 @@ msgid "%(count)d uses" msgstr "%(count)d utilisations" #: bookwyrm/forms.py:251 -#| msgid "Unlisted" msgid "Unlimited" msgstr "Sans limite" @@ -59,7 +58,6 @@ msgid "%(value)s is not a valid username" msgstr "%(value)s n’est pas un nom de compte valide." #: bookwyrm/models/fields.py:165 bookwyrm/templates/layout.html:152 -#| msgid "Username:" msgid "username" msgstr "nom du compte :" @@ -120,64 +118,62 @@ msgstr "Wikipedia" msgid "Books by %(name)s" msgstr "Livres par %(name)s" -#: bookwyrm/templates/book/book.html:21 +#: bookwyrm/templates/book/book.html:33 #: bookwyrm/templates/discover/large-book.html:12 #: bookwyrm/templates/discover/small-book.html:9 msgid "by" msgstr "par" -#: bookwyrm/templates/book/book.html:29 bookwyrm/templates/book/book.html:30 +#: bookwyrm/templates/book/book.html:41 bookwyrm/templates/book/book.html:42 msgid "Edit Book" msgstr "Modifier le livre" -#: bookwyrm/templates/book/book.html:49 +#: bookwyrm/templates/book/book.html:61 #: bookwyrm/templates/book/cover_modal.html:5 msgid "Add cover" msgstr "Ajouter une couverture" -#: bookwyrm/templates/book/book.html:53 -#| msgid "Failed to load" +#: bookwyrm/templates/book/book.html:65 msgid "Failed to load cover" msgstr "La couverture n’a pu être chargée" -#: bookwyrm/templates/book/book.html:62 +#: bookwyrm/templates/book/book.html:74 msgid "ISBN:" msgstr "ISBN :" -#: bookwyrm/templates/book/book.html:69 -#: bookwyrm/templates/book/edit_book.html:217 +#: bookwyrm/templates/book/book.html:81 +#: bookwyrm/templates/book/edit_book.html:223 msgid "OCLC Number:" msgstr "Numéro OCLC :" -#: bookwyrm/templates/book/book.html:76 -#: bookwyrm/templates/book/edit_book.html:221 +#: bookwyrm/templates/book/book.html:88 +#: bookwyrm/templates/book/edit_book.html:227 msgid "ASIN:" msgstr "ASIN :" -#: bookwyrm/templates/book/book.html:85 +#: bookwyrm/templates/book/book.html:97 msgid "View on OpenLibrary" msgstr "Voir sur OpenLibrary" -#: bookwyrm/templates/book/book.html:94 +#: bookwyrm/templates/book/book.html:117 #, python-format msgid "(%(review_count)s review)" msgid_plural "(%(review_count)s reviews)" msgstr[0] "(%(review_count)s critique)" msgstr[1] "(%(review_count)s critiques)" -#: bookwyrm/templates/book/book.html:100 -#| msgid "Description:" +#: bookwyrm/templates/book/book.html:129 msgid "Add Description" msgstr "Ajouter une description" -#: bookwyrm/templates/book/book.html:107 -#: bookwyrm/templates/book/edit_book.html:101 +#: bookwyrm/templates/book/book.html:136 +#: bookwyrm/templates/book/edit_book.html:107 #: bookwyrm/templates/lists/form.html:12 msgid "Description:" msgstr "Description :" -#: bookwyrm/templates/book/book.html:111 -#: bookwyrm/templates/book/edit_book.html:231 +#: bookwyrm/templates/book/book.html:140 +#: bookwyrm/templates/book/edit_book.html:237 #: bookwyrm/templates/edit_author.html:78 bookwyrm/templates/lists/form.html:42 #: bookwyrm/templates/preferences/edit_user.html:70 #: bookwyrm/templates/settings/site.html:93 @@ -188,9 +184,9 @@ msgstr "Description :" msgid "Save" msgstr "Enregistrer" -#: bookwyrm/templates/book/book.html:112 bookwyrm/templates/book/book.html:161 +#: bookwyrm/templates/book/book.html:141 bookwyrm/templates/book/book.html:190 #: bookwyrm/templates/book/cover_modal.html:32 -#: bookwyrm/templates/book/edit_book.html:232 +#: bookwyrm/templates/book/edit_book.html:238 #: bookwyrm/templates/edit_author.html:79 #: bookwyrm/templates/moderation/report_modal.html:32 #: bookwyrm/templates/snippets/delete_readthrough_modal.html:17 @@ -203,92 +199,84 @@ msgstr "Enregistrer" msgid "Cancel" msgstr "Annuler" -#: bookwyrm/templates/book/book.html:121 +#: bookwyrm/templates/book/book.html:150 #, python-format -#| msgid "Editions of \"%(work_title)s\"" msgid "%(count)s editions" msgstr "%(count)s éditions" -#: bookwyrm/templates/book/book.html:129 +#: bookwyrm/templates/book/book.html:158 #, python-format -#| msgid "favorited your %(preview_name)s" msgid "This edition is on your %(shelf_name)s shelf." msgstr "Cette édition est sur votre étagère %(shelf_name)s." -#: bookwyrm/templates/book/book.html:135 +#: bookwyrm/templates/book/book.html:164 #, python-format -#| msgid "replied to your %(preview_name)s" msgid "A different edition of this book is on your %(shelf_name)s shelf." msgstr "Une édition différente de ce livre existe sur votre étagère %(shelf_name)s." -#: bookwyrm/templates/book/book.html:144 +#: bookwyrm/templates/book/book.html:173 msgid "Your reading activity" msgstr "Votre activité de lecture" -#: bookwyrm/templates/book/book.html:146 -#| msgid "Edit read dates" +#: bookwyrm/templates/book/book.html:175 msgid "Add read dates" msgstr "Ajouter des dates de lecture" -#: bookwyrm/templates/book/book.html:151 +#: bookwyrm/templates/book/book.html:180 msgid "You don't have any reading activity for this book." msgstr "Vous n’avez aucune activité de lecture pour ce livre" -#: bookwyrm/templates/book/book.html:158 +#: bookwyrm/templates/book/book.html:187 msgid "Create" msgstr "Créer" -#: bookwyrm/templates/book/book.html:180 +#: bookwyrm/templates/book/book.html:209 msgid "Subjects" msgstr "Sujets" -#: bookwyrm/templates/book/book.html:191 +#: bookwyrm/templates/book/book.html:221 msgid "Places" msgstr "Lieux" -#: bookwyrm/templates/book/book.html:202 bookwyrm/templates/layout.html:64 +#: bookwyrm/templates/book/book.html:232 bookwyrm/templates/layout.html:64 #: bookwyrm/templates/lists/lists.html:5 bookwyrm/templates/lists/lists.html:12 #: bookwyrm/templates/search_results.html:91 #: bookwyrm/templates/user/user_layout.html:62 msgid "Lists" msgstr "Listes" -#: bookwyrm/templates/book/book.html:213 -#| msgid "Go to list" +#: bookwyrm/templates/book/book.html:243 msgid "Add to list" msgstr "Ajouter à la liste" -#: bookwyrm/templates/book/book.html:223 +#: bookwyrm/templates/book/book.html:253 #: bookwyrm/templates/book/cover_modal.html:31 #: bookwyrm/templates/lists/list.html:90 msgid "Add" msgstr "Ajouter" -#: bookwyrm/templates/book/book.html:251 +#: bookwyrm/templates/book/book.html:291 msgid "rated it" msgstr "l’a noté" #: bookwyrm/templates/book/cover_modal.html:17 -#: bookwyrm/templates/book/edit_book.html:169 -#| msgid "Add cover" +#: bookwyrm/templates/book/edit_book.html:175 msgid "Upload cover:" msgstr "Charger une couverture :" #: bookwyrm/templates/book/cover_modal.html:23 -#: bookwyrm/templates/book/edit_book.html:175 +#: bookwyrm/templates/book/edit_book.html:181 msgid "Load cover from url:" msgstr "Charger la couverture depuis une URL :" #: bookwyrm/templates/book/edit_book.html:5 #: bookwyrm/templates/book/edit_book.html:11 #, python-format -#| msgid "Finish \"%(book_title)s\"" msgid "Edit \"%(book_title)s\"" msgstr "Modifier « %(book_title)s »" #: bookwyrm/templates/book/edit_book.html:5 #: bookwyrm/templates/book/edit_book.html:13 -#| msgid "Add Books" msgid "Add Book" msgstr "Ajouter un livre" @@ -318,7 +306,6 @@ msgstr "Est‑ce que l’auteur ou l’autrice « %(name)s » existe déjà #: bookwyrm/templates/book/edit_book.html:52 #, python-format -#| msgid "Start \"%(book_title)s\"" msgid "Author of %(book_title)s" msgstr "Commencer « %(book_title)s »" @@ -354,97 +341,92 @@ msgstr "Retour" msgid "Metadata" msgstr "Métadonnées" -#: bookwyrm/templates/book/edit_book.html:91 +#: bookwyrm/templates/book/edit_book.html:92 msgid "Title:" msgstr "Titre :" -#: bookwyrm/templates/book/edit_book.html:96 +#: bookwyrm/templates/book/edit_book.html:100 msgid "Subtitle:" msgstr "Sous‑titre :" -#: bookwyrm/templates/book/edit_book.html:106 +#: bookwyrm/templates/book/edit_book.html:112 msgid "Series:" msgstr "Série :" -#: bookwyrm/templates/book/edit_book.html:111 +#: bookwyrm/templates/book/edit_book.html:117 msgid "Series number:" msgstr "Numéro dans la série :" -#: bookwyrm/templates/book/edit_book.html:117 -#| msgid "Published" +#: bookwyrm/templates/book/edit_book.html:123 msgid "Publisher:" msgstr "Éditeur :" -#: bookwyrm/templates/book/edit_book.html:119 +#: bookwyrm/templates/book/edit_book.html:125 msgid "Separate multiple publishers with commas." msgstr "Séparez plusieurs éditeurs par une virgule." -#: bookwyrm/templates/book/edit_book.html:126 +#: bookwyrm/templates/book/edit_book.html:132 msgid "First published date:" msgstr "Première date de publication :" -#: bookwyrm/templates/book/edit_book.html:134 +#: bookwyrm/templates/book/edit_book.html:140 msgid "Published date:" msgstr "Date de publication :" -#: bookwyrm/templates/book/edit_book.html:143 -#| msgid "Author" +#: bookwyrm/templates/book/edit_book.html:149 msgid "Authors" msgstr "Auteurs ou autrices" -#: bookwyrm/templates/book/edit_book.html:149 +#: bookwyrm/templates/book/edit_book.html:155 #, python-format -#| msgid "favorited your %(preview_name)s" msgid "Remove %(name)s" msgstr "Supprimer %(name)s" -#: bookwyrm/templates/book/edit_book.html:154 -#| msgid "Edit Author" +#: bookwyrm/templates/book/edit_book.html:160 msgid "Add Authors:" msgstr "Ajouter des auteurs ou autrices :" -#: bookwyrm/templates/book/edit_book.html:155 +#: bookwyrm/templates/book/edit_book.html:161 msgid "John Doe, Jane Smith" msgstr "Claude Dupont, Dominique Durand" -#: bookwyrm/templates/book/edit_book.html:161 +#: bookwyrm/templates/book/edit_book.html:167 #: bookwyrm/templates/user/shelf.html:75 msgid "Cover" msgstr "Couverture" -#: bookwyrm/templates/book/edit_book.html:188 +#: bookwyrm/templates/book/edit_book.html:194 msgid "Physical Properties" msgstr "Propriétés physiques" -#: bookwyrm/templates/book/edit_book.html:189 +#: bookwyrm/templates/book/edit_book.html:195 #: bookwyrm/templates/book/format_filter.html:5 msgid "Format:" msgstr "Format :" -#: bookwyrm/templates/book/edit_book.html:197 +#: bookwyrm/templates/book/edit_book.html:203 msgid "Pages:" msgstr "Pages :" -#: bookwyrm/templates/book/edit_book.html:204 +#: bookwyrm/templates/book/edit_book.html:210 msgid "Book Identifiers" msgstr "Identifiants du livre" -#: bookwyrm/templates/book/edit_book.html:205 +#: bookwyrm/templates/book/edit_book.html:211 msgid "ISBN 13:" msgstr "ISBN 13 :" -#: bookwyrm/templates/book/edit_book.html:209 +#: bookwyrm/templates/book/edit_book.html:215 msgid "ISBN 10:" msgstr "ISBN 10 :" -#: bookwyrm/templates/book/edit_book.html:213 +#: bookwyrm/templates/book/edit_book.html:219 #: bookwyrm/templates/edit_author.html:59 msgid "Openlibrary key:" msgstr "Clé Openlibrary :" #: bookwyrm/templates/book/editions.html:5 #, python-format -#| msgid "Finish \"%(book_title)s\"" msgid "Editions of %(book_title)s" msgstr "Éditions de %(book_title)s" @@ -462,36 +444,37 @@ msgstr "Tou(te)s" msgid "Language:" msgstr "Langue :" -#: bookwyrm/templates/book/publisher_info.html:6 +#: bookwyrm/templates/book/publisher_info.html:22 +#, python-format +msgid "%(format)s" +msgstr "%(format)s" + +#: bookwyrm/templates/book/publisher_info.html:24 #, python-format -#| msgid "of %(book.pages)s pages" msgid "%(format)s, %(pages)s pages" msgstr "%(format)s, %(pages)s pages" -#: bookwyrm/templates/book/publisher_info.html:8 +#: bookwyrm/templates/book/publisher_info.html:26 #, python-format -#| msgid "of %(book.pages)s pages" msgid "%(pages)s pages" msgstr "%(pages)s pages" -#: bookwyrm/templates/book/publisher_info.html:13 +#: bookwyrm/templates/book/publisher_info.html:38 #, python-format -#| msgid "of %(book.pages)s pages" msgid "%(languages)s language" msgstr "%(languages)s langues" -#: bookwyrm/templates/book/publisher_info.html:18 +#: bookwyrm/templates/book/publisher_info.html:65 #, python-format msgid "Published %(date)s by %(publisher)s." msgstr "Publié %(date)s par %(publisher)s." -#: bookwyrm/templates/book/publisher_info.html:20 +#: bookwyrm/templates/book/publisher_info.html:67 #, python-format -#| msgid "Published date:" msgid "Published %(date)s" msgstr "Publié %(date)s" -#: bookwyrm/templates/book/publisher_info.html:22 +#: bookwyrm/templates/book/publisher_info.html:69 #, python-format msgid "Published by %(publisher)s." msgstr "Publié par %(publisher)s." @@ -501,27 +484,22 @@ msgstr "Publié par %(publisher)s." #: bookwyrm/templates/feed/feed_layout.html:70 #: bookwyrm/templates/get_started/layout.html:19 #: bookwyrm/templates/get_started/layout.html:52 -#| msgid "Closed" msgid "Close" msgstr "Fermer" #: bookwyrm/templates/compose.html:5 bookwyrm/templates/compose.html:8 -#| msgid "Boost status" msgid "Compose status" msgstr "Rédiger un statut" #: bookwyrm/templates/directory/community_filter.html:5 -#| msgid "Comment" msgid "Community" msgstr "Communauté" #: bookwyrm/templates/directory/community_filter.html:8 -#| msgid "Blocked users" msgid "Local users" msgstr "Comptes locaux" #: bookwyrm/templates/directory/community_filter.html:12 -#| msgid "Federated" msgid "Federated community" msgstr "Communauté fédérée" @@ -537,7 +515,6 @@ msgstr "Autoriser d’autres utilisateurs ou utilisatrices de BookWyrm à décou #: bookwyrm/templates/directory/directory.html:26 #, python-format -#| msgid "You can set or change your reading goal any time from your profile page" msgid "You can opt-out at any time in your profile settings." msgstr "Vous pouvez décider de ne plus y figurer à n’importe quel moment depuis vos paramètres de profil." @@ -547,14 +524,12 @@ msgid "Dismiss message" msgstr "Rejeter le message" #: bookwyrm/templates/directory/directory.html:71 -#| msgid "followed you" msgid "follower you follow" msgid_plural "followers you follow" msgstr[0] "compte auquel vous êtes abonné(e)" msgstr[1] "comptes auxquels vous êtes abonné(e)" #: bookwyrm/templates/directory/directory.html:78 -#| msgid "Your shelves" msgid "book on your shelves" msgid_plural "books on your shelves" msgstr[0] "livre sur vos étagères" @@ -573,7 +548,6 @@ msgid "Order by" msgstr "Trier par" #: bookwyrm/templates/directory/sort_filter.html:8 -#| msgid "Suggest" msgid "Suggested" msgstr "Suggéré" @@ -582,12 +556,10 @@ msgid "Recently active" msgstr "Actif récemment" #: bookwyrm/templates/directory/user_type_filter.html:5 -#| msgid "User Activity" msgid "User type" msgstr "Type de compte" #: bookwyrm/templates/directory/user_type_filter.html:8 -#| msgid "Blocked users" msgid "BookWyrm users" msgstr "Comptes BookWyrm" @@ -597,7 +569,6 @@ msgstr "Tous les comptes connus" #: bookwyrm/templates/discover/about.html:7 #, python-format -#| msgid "Join %(name)s" msgid "About %(site_name)s" msgstr "À propos de %(site_name)s" @@ -667,7 +638,6 @@ msgid "Your Account" msgstr "Votre compte" #: bookwyrm/templates/edit_author.html:5 -#| msgid "Edit Author" msgid "Edit Author:" msgstr "Modifier l’auteur ou l’autrice :" @@ -722,7 +692,6 @@ msgstr "Paramètres d’email" #: bookwyrm/templates/email/invite/html_content.html:6 #: bookwyrm/templates/email/invite/subject.html:2 #, python-format -#| msgid "Join %(name)s" msgid "You're invited to join %(site_name)s!" msgstr "Vous avez reçu une invitation à rejoindre %(site_name)s !" @@ -741,7 +710,6 @@ msgid "You're invited to join %(site_name)s! Click the link below to create an a msgstr "Vous avez reçu une invitation à rejoindre %(site_name)s ! Cliquez le lien suivant pour créer un compte." #: bookwyrm/templates/email/invite/text_content.html:8 -#| msgid "More about this site" msgid "Learn more about this instance:" msgstr "En savoir plus sur cete instance :" @@ -766,7 +734,6 @@ msgstr "Si vous n’avez pas demandé la réinitialisation de votre mot de passe #: bookwyrm/templates/email/password_reset/subject.html:2 #, python-format -#| msgid "Join %(name)s" msgid "Reset your %(site_name)s password" msgstr "Réinitialiser votre mot de passe sur %(site_name)s" @@ -793,12 +760,10 @@ msgid "Home Timeline" msgstr "Mon fil d’actualité" #: bookwyrm/templates/feed/feed.html:11 -#| msgid "%(tab_title)s Timeline" msgid "Local Timeline" msgstr "Fil d’actualité local" #: bookwyrm/templates/feed/feed.html:13 -#| msgid "Federated Servers" msgid "Federated Timeline" msgstr "Fil d’actualité des instances fédérées" @@ -829,7 +794,6 @@ msgid "Who to follow" msgstr "À qui s’abonner" #: bookwyrm/templates/feed/feed_layout.html:5 -#| msgid "Updated:" msgid "Updates" msgstr "Mises à jour" @@ -845,13 +809,11 @@ msgstr "Aucun livre ici pour l’instant ! Cherchez un livre pour commencer" #: bookwyrm/templates/feed/feed_layout.html:24 #: bookwyrm/templates/user/shelf.html:28 -#| msgid "Read" msgid "To Read" msgstr "À lire" #: bookwyrm/templates/feed/feed_layout.html:25 #: bookwyrm/templates/user/shelf.html:28 -#| msgid "Started reading" msgid "Currently Reading" msgstr "En train de lire" @@ -883,12 +845,10 @@ msgstr[1] "%(shared_books)s livres sur vos étagères" #: bookwyrm/templates/get_started/book_preview.html:6 #, python-format -#| msgid "Want to Read \"%(book_title)s\"" msgid "Have you read %(book_title)s?" msgstr "Avez‑vous lu « %(book_title)s » ?" #: bookwyrm/templates/get_started/books.html:6 -#| msgid "Started reading" msgid "What are you reading?" msgstr "Que lisez‑vous ?" @@ -919,13 +879,11 @@ msgid "Search" msgstr "Chercher" #: bookwyrm/templates/get_started/books.html:26 -#| msgid "Suggest Books" msgid "Suggested Books" msgstr "Suggérer des livres" #: bookwyrm/templates/get_started/books.html:41 #, python-format -#| msgid "Join %(name)s" msgid "Popular on %(site_name)s" msgstr "Populaire sur %(site_name)s" @@ -941,7 +899,6 @@ msgstr "Enregistrer & continuer" #: bookwyrm/templates/get_started/layout.html:14 #, python-format -#| msgid "Join %(name)s" msgid "Welcome to %(site_name)s!" msgstr "Bienvenu(e) sur %(site_name)s !" @@ -951,17 +908,14 @@ msgstr "Voici quelques étapes pour commencer votre profil." #: bookwyrm/templates/get_started/layout.html:30 #: bookwyrm/templates/get_started/profile.html:6 -#| msgid "User Profile" msgid "Create your profile" msgstr "Créez votre profil" #: bookwyrm/templates/get_started/layout.html:34 -#| msgid "Add Books" msgid "Add books" msgstr "Ajoutez des livres" #: bookwyrm/templates/get_started/layout.html:38 -#| msgid "Friendly" msgid "Find friends" msgstr "Établissez des contacts" @@ -970,7 +924,6 @@ msgid "Skip this step" msgstr "Passer cette étape" #: bookwyrm/templates/get_started/layout.html:48 -#| msgid "Finished" msgid "Finish" msgstr "Terminer" @@ -1008,7 +961,6 @@ msgid "Your account will show up in the directory, and may be recommended to oth msgstr "Votre compte sera listé dans le répertoire et pourra être recommandé à d’autres utilisateurs ou utilisatrices de BookWyrm." #: bookwyrm/templates/get_started/users.html:11 -#| msgid "Search for a book or user" msgid "Search for a user" msgstr "Chercher un compte" @@ -1024,7 +976,6 @@ msgid "%(year)s Reading Progress" msgstr "Progression de lecture pour %(year)s" #: bookwyrm/templates/goal.html:11 -#| msgid "Edit Book" msgid "Edit Goal" msgstr "Modifier le défi" @@ -1041,13 +992,11 @@ msgstr "%(name)s n’a aucun défi lecture pour %(year)s." #: bookwyrm/templates/goal.html:51 #, python-format -#| msgid "Your books" msgid "Your %(year)s Books" msgstr "Vos livres en %(year)s" #: bookwyrm/templates/goal.html:53 #, python-format -#| msgid "%(username)s has no followers" msgid "%(username)s's %(year)s Books" msgstr "Livres de %(username)s en %(year)s" @@ -1057,7 +1006,6 @@ msgid "Import Books" msgstr "Importer des livres" #: bookwyrm/templates/import.html:16 -#| msgid "Data source" msgid "Data source:" msgstr "Source de données :" @@ -1193,7 +1141,6 @@ msgid "Feed" msgstr "Fil d’actualité" #: bookwyrm/templates/layout.html:102 -#| msgid "Instance Settings" msgid "Settings" msgstr "Paramètres" @@ -1226,7 +1173,6 @@ msgid "Username:" msgstr "Nom du compte :" #: bookwyrm/templates/layout.html:156 -#| msgid "Password:" msgid "password" msgstr "Mot de passe" @@ -1267,13 +1213,11 @@ msgstr "Créer une liste" #: bookwyrm/templates/lists/created_text.html:5 #, python-format -#| msgid "favorited your %(preview_name)s" msgid "Created and curated by %(username)s" msgstr "Créée et modérée par %(username)s" #: bookwyrm/templates/lists/created_text.html:7 #, python-format -#| msgid "favorited your %(preview_name)s" msgid "Created by %(username)s" msgstr "Créée par %(username)s" @@ -1341,7 +1285,6 @@ msgstr "Cette liste est vide actuellement" #: bookwyrm/templates/lists/list.html:35 #, python-format -#| msgid "favorited your %(preview_name)s" msgid "Added by %(username)s" msgstr "Ajoutée par %(username)s" @@ -1375,6 +1318,10 @@ msgstr "Aucun livre trouvé pour la requête « %(query)s »" msgid "Suggest" msgstr "Suggérer" +#: bookwyrm/templates/lists/lists.html:14 bookwyrm/templates/user/lists.html:9 +msgid "Your Lists" +msgstr "Vos listes" + #: bookwyrm/templates/login.html:4 msgid "Login" msgstr "Connexion" @@ -1404,12 +1351,10 @@ msgid "Back to reports" msgstr "Retour aux signalements" #: bookwyrm/templates/moderation/report.html:18 -#| msgid "Notifications" msgid "Actions" msgstr "Actions" #: bookwyrm/templates/moderation/report.html:19 -#| msgid "User Profile" msgid "View user profile" msgstr "Voir le profil" @@ -1438,7 +1383,6 @@ msgid "Comment" msgstr "Commentaire" #: bookwyrm/templates/moderation/report.html:59 -#| msgid "Delete status" msgid "Reported statuses" msgstr "Statuts signalés" @@ -1452,7 +1396,6 @@ msgstr "Les statuts ont été supprimés" #: bookwyrm/templates/moderation/report_modal.html:6 #, python-format -#| msgid "Join %(name)s" msgid "Report @%(username)s" msgstr "Signaler @%(username)s" @@ -1462,7 +1405,6 @@ msgid "This report will be sent to %(site_name)s's moderators for review." msgstr "Ce signalement sera envoyé à l’équipe de modération de %(site_name)s pour traitement." #: bookwyrm/templates/moderation/report_modal.html:22 -#| msgid "More about this site" msgid "More info about this report:" msgstr "En savoir plus sur ce signalement :" @@ -1472,7 +1414,6 @@ msgstr "Aucune note fournie" #: bookwyrm/templates/moderation/report_preview.html:20 #, python-format -#| msgid "favorited your %(preview_name)s" msgid "Reported by %(username)s" msgstr "Signalé par %(username)s" @@ -1486,30 +1427,25 @@ msgstr "Résoudre" #: bookwyrm/templates/moderation/reports.html:6 #, python-format -#| msgid "Join %(name)s" msgid "Reports: %(server_name)s" msgstr "Signalements : %(server_name)s" #: bookwyrm/templates/moderation/reports.html:8 #: bookwyrm/templates/moderation/reports.html:16 #: bookwyrm/templates/settings/admin_layout.html:28 -#| msgid "Recent Imports" msgid "Reports" msgstr "Signalements" #: bookwyrm/templates/moderation/reports.html:13 #, python-format -#| msgid "Join %(name)s" msgid "Reports: %(server_name)s" msgstr "Signalements: %(server_name)s" #: bookwyrm/templates/moderation/reports.html:27 -#| msgid "Shelved" msgid "Resolved" msgstr "Résolus" #: bookwyrm/templates/moderation/reports.html:34 -#| msgid "No books found" msgid "No reports found." msgstr "Aucun signalement trouvé." @@ -1741,7 +1677,6 @@ msgstr "Paramètres de l’instance" #: bookwyrm/templates/settings/admin_layout.html:41 #: bookwyrm/templates/settings/site.html:4 #: bookwyrm/templates/settings/site.html:6 -#| msgid "Instance Settings" msgid "Site Settings" msgstr "Paramètres du site" @@ -1774,17 +1709,14 @@ msgid "Details" msgstr "Détails" #: bookwyrm/templates/settings/federated_server.html:15 -#| msgid "Software" msgid "Software:" msgstr "Logiciel :" #: bookwyrm/templates/settings/federated_server.html:19 -#| msgid "Description:" msgid "Version:" msgstr "Description :" #: bookwyrm/templates/settings/federated_server.html:23 -#| msgid "Status" msgid "Status:" msgstr "Statut :" @@ -1794,7 +1726,6 @@ msgid "Activity" msgstr "Activité" #: bookwyrm/templates/settings/federated_server.html:33 -#| msgid "Username:" msgid "Users:" msgstr "Comptes :" @@ -1804,22 +1735,18 @@ msgid "View all" msgstr "Voir tous" #: bookwyrm/templates/settings/federated_server.html:40 -#| msgid "Recent Imports" msgid "Reports:" msgstr "Signalements :" #: bookwyrm/templates/settings/federated_server.html:47 -#| msgid "followed you" msgid "Followed by us:" msgstr "Suivi par nous :" #: bookwyrm/templates/settings/federated_server.html:53 -#| msgid "followed you" msgid "Followed by them:" msgstr "Suivi par eux :" #: bookwyrm/templates/settings/federated_server.html:59 -#| msgid "Blocked Users" msgid "Blocked by us:" msgstr "Bloqués par nous :" @@ -1828,7 +1755,6 @@ msgid "Server name" msgstr "Nom du serveur" #: bookwyrm/templates/settings/federation.html:17 -#| msgid "Federated" msgid "Date federated" msgstr "Date de fédération" @@ -1847,7 +1773,6 @@ msgstr "Statut" #: bookwyrm/templates/settings/manage_invite_requests.html:11 #: bookwyrm/templates/settings/manage_invite_requests.html:25 #: bookwyrm/templates/settings/manage_invites.html:11 -#| msgid "Invites" msgid "Invite Requests" msgstr "Invitations" @@ -1856,12 +1781,10 @@ msgid "Ignored Invite Requests" msgstr "Invitations ignorées" #: bookwyrm/templates/settings/manage_invite_requests.html:35 -#| msgid "Federated" msgid "Date requested" msgstr "Date d’envoi" #: bookwyrm/templates/settings/manage_invite_requests.html:39 -#| msgid "Accept" msgid "Date accepted" msgstr "Date de validation" @@ -1870,18 +1793,15 @@ msgid "Email" msgstr "Email" #: bookwyrm/templates/settings/manage_invite_requests.html:47 -#| msgid "Notifications" msgid "Action" msgstr "Action" #: bookwyrm/templates/settings/manage_invite_requests.html:50 -#| msgid "Follow Requests" msgid "No requests" msgstr "Aucune demande" #: bookwyrm/templates/settings/manage_invite_requests.html:59 #: bookwyrm/templates/settings/status_filter.html:16 -#| msgid "Accept" msgid "Accepted" msgstr "Accepté(e)s" @@ -2004,7 +1924,6 @@ msgid "Allow registration:" msgstr "Autoriser l’enregistrement :" #: bookwyrm/templates/settings/site.html:83 -#| msgid "Follow Requests" msgid "Allow invite requests:" msgstr "Autoriser les demandes d’invitation :" @@ -2018,12 +1937,10 @@ msgid "Users: %(server_name)s" msgstr "Comptes : %(server_name)s" #: bookwyrm/templates/settings/user_admin.html:20 -#| msgid "Username:" msgid "Username" msgstr "Nom du compte" #: bookwyrm/templates/settings/user_admin.html:24 -#| msgid "added" msgid "Date Added" msgstr "Date d’ajout" @@ -2032,12 +1949,10 @@ msgid "Last Active" msgstr "Dernière activité" #: bookwyrm/templates/settings/user_admin.html:36 -#| msgid "Remove" msgid "Remote server" msgstr "Serveur distant" #: bookwyrm/templates/settings/user_admin.html:45 -#| msgid "Activity" msgid "Active" msgstr "Actif" @@ -2057,6 +1972,11 @@ msgstr "Bloquer" msgid "Un-block" msgstr "Débloquer" +#: bookwyrm/templates/snippets/book_cover.html:20 +#: bookwyrm/templates/snippets/search_result_text.html:10 +msgid "No cover" +msgstr "Aucune couverture" + #: bookwyrm/templates/snippets/book_titleby.html:3 #, python-format msgid "%(title)s by " @@ -2095,12 +2015,10 @@ msgid "Comment:" msgstr "Commentaire :" #: bookwyrm/templates/snippets/create_status_form.html:20 -#| msgid "Quote" msgid "Quote:" msgstr "Citation :" #: bookwyrm/templates/snippets/create_status_form.html:22 -#| msgid "Review" msgid "Review:" msgstr "Critique :" @@ -2174,7 +2092,6 @@ msgid "Un-like status" msgstr "Retirer le statut des favoris" #: bookwyrm/templates/snippets/filters_panel/filters_panel.html:7 -#| msgid "Show less" msgid "Show filters" msgstr "Afficher les filtres" @@ -2187,7 +2104,6 @@ msgid "Apply filters" msgstr "Appliquer les filtres" #: bookwyrm/templates/snippets/filters_panel/filters_panel.html:26 -#| msgid "Clear search" msgid "Clear filters" msgstr "Annuler les filtres" @@ -2196,7 +2112,6 @@ msgid "Follow" msgstr "S’abonner" #: bookwyrm/templates/snippets/follow_button.html:18 -#| msgid "Send follow request" msgid "Undo follow request" msgstr "Annuler la demande d’abonnement" @@ -2230,7 +2145,6 @@ msgstr[1] "souhaite lire %(counter)s livres en %(year)s" #: bookwyrm/templates/snippets/generated_status/rating.html:3 #, python-format -#| msgid "%(title)s by " msgid "Rated %(title)s: %(display_rating)s star" msgid_plural "Rated %(title)s: %(display_rating)s stars" msgstr[0] "A noté %(title)s : %(display_rating)s star" @@ -2297,13 +2211,11 @@ msgstr "%(username)s a lu %(read_count)s sur %(goal_count)s #: bookwyrm/templates/snippets/page_text.html:4 #, python-format -#| msgid "of %(pages)s pages" msgid "page %(page)s of %(total_pages)s" msgstr "page %(page)s sur %(total_pages)s pages" #: bookwyrm/templates/snippets/page_text.html:6 #, python-format -#| msgid "of %(book.pages)s pages" msgid "page %(page)s" msgstr "page %(page)s" @@ -2374,7 +2286,6 @@ msgid "Edit read dates" msgstr "Modifier les date de lecture" #: bookwyrm/templates/snippets/readthrough.html:61 -#| msgid "Delete these read dates?" msgid "Delete these read dates" msgstr "Supprimer ces dates de lecture" @@ -2398,35 +2309,29 @@ msgid "Sign Up" msgstr "S’enregistrer" #: bookwyrm/templates/snippets/report_button.html:5 -#| msgid "Import" msgid "Report" msgstr "Signaler" #: bookwyrm/templates/snippets/rss_title.html:5 -#: bookwyrm/templates/snippets/status/status_header.html:11 +#: bookwyrm/templates/snippets/status/status_header.html:21 msgid "rated" msgstr "a noté" #: bookwyrm/templates/snippets/rss_title.html:7 -#: bookwyrm/templates/snippets/status/status_header.html:13 +#: bookwyrm/templates/snippets/status/status_header.html:23 msgid "reviewed" msgstr "a écrit une critique de" #: bookwyrm/templates/snippets/rss_title.html:9 -#: bookwyrm/templates/snippets/status/status_header.html:15 +#: bookwyrm/templates/snippets/status/status_header.html:25 msgid "commented on" msgstr "a commenté" #: bookwyrm/templates/snippets/rss_title.html:11 -#: bookwyrm/templates/snippets/status/status_header.html:17 +#: bookwyrm/templates/snippets/status/status_header.html:27 msgid "quoted" msgstr "a cité" -#: bookwyrm/templates/snippets/search_result_text.html:10 -#| msgid "Add cover" -msgid "No cover" -msgstr "Aucune couverture" - #: bookwyrm/templates/snippets/search_result_text.html:22 #, python-format msgid "by %(author)s" @@ -2437,7 +2342,6 @@ msgid "Import book" msgstr "Importer le livre" #: bookwyrm/templates/snippets/shelf_selector.html:4 -#| msgid "Your books" msgid "Move book" msgstr "Déplacer le livre" @@ -2448,7 +2352,6 @@ msgstr "Terminer « %(book_title)s »" #: bookwyrm/templates/snippets/shelve_button/progress_update_modal.html:5 #: bookwyrm/templates/snippets/shelve_button/shelve_button_options.html:35 -#| msgid "Updated:" msgid "Update progress" msgstr "Progression de la mise à jour" @@ -2457,12 +2360,10 @@ msgid "More shelves" msgstr "Plus d’étagères" #: bookwyrm/templates/snippets/shelve_button/shelve_button_options.html:8 -#| msgid "Started reading" msgid "Start reading" msgstr "Commencer la lecture" #: bookwyrm/templates/snippets/shelve_button/shelve_button_options.html:13 -#| msgid "Finished reading" msgid "Finish reading" msgstr "Terminer la lecture" @@ -2473,7 +2374,6 @@ msgstr "Je veux le lire" #: bookwyrm/templates/snippets/shelve_button/shelve_button_options.html:48 #, python-format -#| msgid "Join %(name)s" msgid "Remove from %(name)s" msgstr "Retirer de %(name)s" @@ -2502,41 +2402,37 @@ msgstr "Supprimer le statut" msgid "Reply" msgstr "Répondre" -#: bookwyrm/templates/snippets/status/status_content.html:18 +#: bookwyrm/templates/snippets/status/status_content.html:52 #: bookwyrm/templates/snippets/trimmed_text.html:15 msgid "Show more" msgstr "Déplier" -#: bookwyrm/templates/snippets/status/status_content.html:25 -#: bookwyrm/templates/snippets/trimmed_text.html:25 +#: bookwyrm/templates/snippets/status/status_content.html:67 +#: bookwyrm/templates/snippets/trimmed_text.html:30 msgid "Show less" msgstr "Replier" -#: bookwyrm/templates/snippets/status/status_content.html:46 +#: bookwyrm/templates/snippets/status/status_content.html:97 msgid "Open image in new window" msgstr "Ouvrir l’image dans une nouvelle fenêtre" -#: bookwyrm/templates/snippets/status/status_header.html:22 +#: bookwyrm/templates/snippets/status/status_header.html:32 #, python-format -#| msgid "favorited your %(preview_name)s" msgid "replied to %(username)s's review" msgstr "a répondu à la critique de %(username)s" -#: bookwyrm/templates/snippets/status/status_header.html:24 +#: bookwyrm/templates/snippets/status/status_header.html:34 #, python-format -#| msgid "replied to your status" msgid "replied to %(username)s's comment" msgstr "a répondu au commentaire de %(username)s" -#: bookwyrm/templates/snippets/status/status_header.html:26 +#: bookwyrm/templates/snippets/status/status_header.html:36 #, python-format -#| msgid "replied to your status" msgid "replied to %(username)s's quote" msgstr "a répondu à la citation de %(username)s" -#: bookwyrm/templates/snippets/status/status_header.html:28 +#: bookwyrm/templates/snippets/status/status_header.html:38 #, python-format -#| msgid "replied to your status" msgid "replied to %(username)s's status" msgstr "a répondu au statut de %(username)s" @@ -2546,7 +2442,6 @@ msgid "More options" msgstr "Plus d’options" #: bookwyrm/templates/snippets/status/status_options.html:27 -#| msgid "Delete these read dates?" msgid "Delete & re-draft" msgstr "Supprimer & recommencer la rédaction" @@ -2555,12 +2450,10 @@ msgid "Switch to this edition" msgstr "Changer vers cette édition" #: bookwyrm/templates/snippets/table-sort-header.html:6 -#| msgid "Started reading" msgid "Sorted ascending" msgstr "Trié par ordre croissant" #: bookwyrm/templates/snippets/table-sort-header.html:10 -#| msgid "Started reading" msgid "Sorted descending" msgstr "Trié par ordre décroissant" @@ -2579,7 +2472,6 @@ msgstr "Livres tagués « %(tag.name)s »" #: bookwyrm/templates/user/books_header.html:5 #, python-format -#| msgid "%(username)s has no followers" msgid "%(username)s's books" msgstr "Livres de %(username)s" @@ -2616,13 +2508,8 @@ msgstr "Abonné(e) à" msgid "%(username)s isn't following any users" msgstr "%(username)s n’est abonné(e) à personne" -#: bookwyrm/templates/user/lists.html:9 -msgid "Your Lists" -msgstr "Vos listes" - #: bookwyrm/templates/user/lists.html:11 #, python-format -#| msgid "Join %(name)s" msgid "Lists: %(username)s" msgstr "Listes : %(username)s" @@ -2631,7 +2518,6 @@ msgid "Create list" msgstr "Créer une liste" #: bookwyrm/templates/user/shelf.html:24 bookwyrm/views/shelf.py:56 -#| msgid "books" msgid "All books" msgstr "Tous les livres" @@ -2669,7 +2555,6 @@ msgstr "Modifier le profil" #: bookwyrm/templates/user/user.html:34 #, python-format -#| msgid "See all %(size)s" msgid "View all %(size)s" msgstr "Voir les %(size)s" @@ -2703,7 +2588,6 @@ msgid "Reading Goal" msgstr "Défi lecture" #: bookwyrm/templates/user/user_layout.html:68 -#| msgid "Book" msgid "Books" msgstr "Livres" @@ -2714,7 +2598,6 @@ msgstr "Enregistré(e) %(date)s" #: bookwyrm/templates/user/user_preview.html:15 #, python-format -#| msgid "%(username)s has no followers" msgid "%(counter)s follower" msgid_plural "%(counter)s followers" msgstr[0] "%(counter)s abonnement" diff --git a/nginx/default.conf b/nginx/development similarity index 100% rename from nginx/default.conf rename to nginx/development diff --git a/nginx/production b/nginx/production new file mode 100644 index 000000000..c5d83cbf6 --- /dev/null +++ b/nginx/production @@ -0,0 +1,72 @@ +upstream web { + server web:8000; +} + +server { + listen [::]:80; + listen 80; + + server_name your-domain.com www.your-domain.com; + + location ~ /.well-known/acme-challenge { + allow all; + root /var/www/certbot; + } + +# # redirect http to https +# return 301 https://your-domain.com$request_uri; +# } +# +# server { +# listen [::]:443 ssl http2; +# listen 443 ssl http2; +# +# server_name your-domain.com; +# +# # SSL code +# ssl_certificate /etc/nginx/ssl/live/your-domain.com/fullchain.pem; +# ssl_certificate_key /etc/nginx/ssl/live/your-domain.com/privkey.pem; +# +# location ~ /.well-known/acme-challenge { +# allow all; +# root /var/www/certbot; +# } +# +# location / { +# proxy_pass http://web; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header Host $host; +# proxy_redirect off; +# } +# +# location /images/ { +# alias /app/images/; +# } +# +# location /static/ { +# alias /app/static/; +# } +} + +# Reverse-Proxy server +# server { +# listen [::]:8001; +# listen 8001; + +# server_name your-domain.com www.your-domain.com; + +# location / { +# proxy_pass http://web; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header Host $host; +# proxy_redirect off; +# } + +# location /images/ { +# alias /app/images/; +# } + +# location /static/ { +# alias /app/static/; +# } +# } diff --git a/package.json b/package.json index 8059255d9..b7abe4342 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,12 @@ { + "scripts": { + "watch:static": "yarn watch \"./bw-dev collectstatic\" bookwyrm/static/**" + }, "devDependencies": { "eslint": "^7.23.0", "stylelint": "^13.12.0", "stylelint-config-standard": "^21.0.0", - "stylelint-order": "^4.1.0" + "stylelint-order": "^4.1.0", + "watch": "^1.0.2" } } diff --git a/requirements.txt b/requirements.txt index 16561da58..6b7d82d34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ celery==4.4.2 -Django==3.1.6 +Django==3.1.8 django-model-utils==4.0.0 environs==7.2.0 flower==0.9.4 diff --git a/yarn.lock b/yarn.lock index de4e0107e..c1a1c1812 100644 --- a/yarn.lock +++ b/yarn.lock @@ -768,6 +768,13 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== +exec-sh@^0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36" + integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw== + dependencies: + merge "^1.2.0" + execall@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45" @@ -1368,6 +1375,11 @@ merge2@^1.3.0: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +merge@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145" + integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ== + micromark@~2.11.0: version "2.11.4" resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.4.tgz#d13436138eea826383e822449c9a5c50ee44665a" @@ -1405,7 +1417,7 @@ minimist-options@4.1.0: is-plain-obj "^1.1.0" kind-of "^6.0.3" -minimist@^1.2.5: +minimist@^1.2.0, minimist@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== @@ -2183,6 +2195,14 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" +watch@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/watch/-/watch-1.0.2.tgz#340a717bde765726fa0aa07d721e0147a551df0c" + integrity sha1-NApxe952Vyb6CqB9ch4BR6VR3ww= + dependencies: + exec-sh "^0.2.0" + minimist "^1.2.0" + which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
{{ user.username }}{{ user.username }} {{ user.created_date }} {{ user.last_active_date }} {% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}