Merge branch 'main' into domain-block

This commit is contained in:
Mouse Reeve
2021-04-10 09:26:01 -07:00
32 changed files with 1877 additions and 1407 deletions

View File

@ -36,3 +36,4 @@ EMAIL_PORT=587
EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_USER=mail@your.domain.here
EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true EMAIL_USE_TLS=true
EMAIL_USE_SSL=false

View File

@ -156,24 +156,6 @@ The `production` branch of BookWyrm contains a number of tools not on the `main`
Instructions for running BookWyrm in production: Instructions for running BookWyrm in production:
- Get the application code:
`git clone git@github.com:mouse-reeve/bookwyrm.git`
- Switch to the `production` branch
`git checkout production`
- Create your environment variables file
`cp .env.example .env`
- Add your domain, email address, SMTP credentials
- Set a secure redis password and secret key
- Set a secure database password for postgres
- Update your nginx configuration in `nginx/default.conf`
- Replace `your-domain.com` with your domain name
- Run the application (this should also set up a Certbot ssl cert for your domain) with
`docker-compose up --build`, and make sure all the images build successfully
- When docker has built successfully, stop the process with `CTRL-C`
- Comment out the `command: certonly...` line in `docker-compose.yml`
- Run docker-compose in the background with: `docker-compose up -d`
- Initialize the database with: `./bw-dev initdb`
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe location
- Get the application code: - Get the application code:
`git clone git@github.com:mouse-reeve/bookwyrm.git` `git clone git@github.com:mouse-reeve/bookwyrm.git`
- Switch to the `production` branch - Switch to the `production` branch

View File

@ -0,0 +1,27 @@
# Generated by Django 3.1.6 on 2021-04-08 15:56
import bookwyrm.models.fields
import django.contrib.postgres.fields.citext
import django.contrib.postgres.operations
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0062_auto_20210407_1545"),
]
operations = [
django.contrib.postgres.operations.CITextExtension(),
migrations.AlterField(
model_name="user",
name="localname",
field=django.contrib.postgres.fields.citext.CICharField(
max_length=255,
null=True,
unique=True,
validators=[bookwyrm.models.fields.validate_localname],
),
),
]

View File

@ -4,6 +4,7 @@ from urllib.parse import urlparse
from django.apps import apps from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import CICharField
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
@ -54,7 +55,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
summary = fields.HtmlField(null=True, blank=True) summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False) local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True) bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField( localname = CICharField(
max_length=255, max_length=255,
null=True, null=True,
unique=True, unique=True,

View File

@ -24,7 +24,8 @@ EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env("EMAIL_PORT", 587) EMAIL_PORT = env("EMAIL_PORT", 587)
EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env("EMAIL_USE_TLS", True) EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN")) DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)

View File

@ -6,14 +6,26 @@
{% block title %}{{ book.title }}{% endblock %} {% block title %}{{ book.title }}{% endblock %}
{% block content %} {% block content %}
<div class="block"> {% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
<div class="block" itemscope itemtype="https://schema.org/Book">
<div class="columns is-mobile"> <div class="columns is-mobile">
<div class="column"> <div class="column">
<h1 class="title"> <h1 class="title">
<span itemprop="name">
{{ book.title }}{% if book.subtitle %}: {{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>{% endif %} <small>{{ book.subtitle }}</small>
{% endif %}
</span>
{% if book.series %} {% if book.series %}
<small class="has-text-grey-dark">({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})</small><br> <meta itemprop="isPartOf" content="{{ book.series }}">
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
<small class="has-text-grey-dark">
({{ book.series }}
{% if book.series_number %} #{{ book.series_number }}{% endif %})
</small>
<br>
{% endif %} {% endif %}
</h1> </h1>
{% if book.authors %} {% if book.authors %}
@ -23,7 +35,7 @@
{% endif %} {% endif %}
</div> </div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %} {% if user_authenticated and can_edit_book %}
<div class="column is-narrow"> <div class="column is-narrow">
<a href="{{ book.id }}/edit"> <a href="{{ book.id }}/edit">
<span class="icon icon-pencil" title="{% trans "Edit Book" %}"> <span class="icon icon-pencil" title="{% trans "Edit Book" %}">
@ -44,7 +56,7 @@
{% include 'snippets/shelve_button/shelve_button.html' %} {% include 'snippets/shelve_button/shelve_button.html' %}
</div> </div>
{% if request.user.is_authenticated and not book.cover %} {% if user_authenticated and not book.cover %}
<div class="block"> <div class="block">
{% trans "Add cover" as button_text %} {% trans "Add cover" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %} {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %}
@ -60,7 +72,7 @@
{% if book.isbn_13 %} {% if book.isbn_13 %}
<div class="is-flex is-justify-content-space-between is-align-items-center"> <div class="is-flex is-justify-content-space-between is-align-items-center">
<dt>{% trans "ISBN:" %}</dt> <dt>{% trans "ISBN:" %}</dt>
<dd>{{ book.isbn_13 }}</dd> <dd itemprop="isbn">{{ book.isbn_13 }}</dd>
</div> </div>
{% endif %} {% endif %}
@ -89,14 +101,31 @@
<div class="column is-three-fifths"> <div class="column is-three-fifths">
<div class="block"> <div class="block">
<h3 class="field is-grouped"> <h3
class="field is-grouped"
itemprop="aggregateRating"
itemscope
itemtype="https://schema.org/AggregateRating"
>
<meta itemprop="ratingValue" content="{{ rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
<meta itemprop="reviewCount" content="{{ review_count }}">
{% include 'snippets/stars.html' with rating=rating %} {% include 'snippets/stars.html' with rating=rating %}
{% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %}
{% blocktrans count counter=review_count trimmed %}
({{ review_count }} review)
{% plural %}
({{ review_count }} reviews)
{% endblocktrans %}
</h3> </h3>
{% include 'snippets/trimmed_text.html' with full=book|book_description %} {% with full=book|book_description itemprop='abstract' %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %} {% if user_authenticated and can_edit_book and not book|book_description %}
{% trans 'Add Description' as button_text %} {% trans 'Add Description' as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %} {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
@ -138,7 +167,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% if request.user.is_authenticated %} {% if user_authenticated %}
<section class="block"> <section class="block">
<header class="columns"> <header class="columns">
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2> <h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
@ -178,9 +207,10 @@
{% if book.subjects %} {% if book.subjects %}
<section class="content block"> <section class="content block">
<h2 class="title is-5">{% trans "Subjects" %}</h2> <h2 class="title is-5">{% trans "Subjects" %}</h2>
<ul> <ul>
{% for subject in book.subjects %} {% for subject in book.subjects %}
<li>{{ subject }}</li> <li itemprop="about">{{ subject }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
</section> </section>
@ -229,26 +259,37 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<div class="block" id="reviews"> <div class="block" id="reviews">
{% for review in reviews %} {% for review in reviews %}
<div class="block"> <div
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %} class="block"
itemprop="review"
itemscope
itemtype="https://schema.org/Review"
>
{% with status=review hide_book=True depth=1 %}
{% include 'snippets/status/status.html' %}
{% endwith %}
</div> </div>
{% endfor %} {% endfor %}
<div class="block is-flex is-flex-wrap-wrap"> <div class="block is-flex is-flex-wrap-wrap">
{% for rating in ratings %} {% for rating in ratings %}
{% with user=rating.user %}
<div class="block mr-5"> <div class="block mr-5">
<div class="media"> <div class="media">
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div> <div class="media-left">
{% include 'snippets/avatar.html' %}
</div>
<div class="media-content"> <div class="media-content">
<div> <div>
<a href="{{ rating.user.local_path }}">{{ rating.user.display_name }}</a> <a href="{{ user.local_path }}">{{ user.display_name }}</a>
</div> </div>
<div class="is-flex"> <div class="is-flex">
<p class="mr-1">{% trans "rated it" %}</p> <p class="mr-1">{% trans "rated it" %}</p>
{% include 'snippets/stars.html' with rating=rating.rating %} {% include 'snippets/stars.html' with rating=rating.rating %}
</div> </div>
<div> <div>
@ -257,13 +298,15 @@
</div> </div>
</div> </div>
</div> </div>
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
<div class="block"> <div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %} {% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
</div> </div>
</div> </div>
</div>
{% endwith %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}

View File

@ -90,7 +90,7 @@
<h2 class="title is-4">{% trans "Metadata" %}</h2> <h2 class="title is-4">{% trans "Metadata" %}</h2>
<p class="mb-2"> <p class="mb-2">
<label class="label" for="id_title">{% trans "Title:" %}</label> <label class="label" for="id_title">{% trans "Title:" %}</label>
<input type="text" name="title" value="{{ form.title.value }}" maxlength="255" class="input" required="" id="id_title"> <input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
</p> </p>
{% for error in form.title.errors %} {% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
@ -98,7 +98,7 @@
<p class="mb-2"> <p class="mb-2">
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> <label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
<input type="text" name="subtitle" value="{{ form.subtitle.value }}" maxlength="255" class="input" required="" id="id_subtitle"> <input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
</p> </p>
{% for error in form.subtitle.errors %} {% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
@ -130,7 +130,7 @@
<p class="mb-2"> <p class="mb-2">
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label> <label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if book.first_published_date %} value="{{ book.first_published_date|date:'Y-m-d' }}"{% endif %}> <input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %}>
</p> </p>
{% for error in form.first_published_date.errors %} {% for error in form.first_published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
@ -138,7 +138,7 @@
<p class="mb-2"> <p class="mb-2">
<label class="label" for="id_published_date">{% trans "Published date:" %}</label> <label class="label" for="id_published_date">{% trans "Published date:" %}</label>
<input type="date" name="published_date" class="input" id="id_published_date"{% if book.published_date %} value="{{ book.published_date|date:'Y-m-d' }}"{% endif %}> <input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %}>
</p> </p>
{% for error in form.published_date.errors %} {% for error in form.published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>

View File

@ -1,24 +1,69 @@
{% spaceless %}
{% load i18n %} {% load i18n %}
<p> <p>
{% if book.physical_format and not book.pages %} {% with format=book.physical_format pages=book.pages %}
{{ book.physical_format | title }} {% if format %}
{% elif book.physical_format and book.pages %} {% comment %}
{% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %} @todo The bookFormat property is limited to a list of values whereas the book edition is free text.
{% elif book.pages %} @see https://schema.org/bookFormat
{% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %} {% endcomment %}
<meta itemprop="bookFormat" content="{{ format }}">
{% endif %} {% endif %}
{% if pages %}
<meta itemprop="numberOfPages" content="{{ pages }}">
{% endif %}
{% if format and not pages %}
{% blocktrans %}{{ format }}{% endblocktrans %}
{% elif format and pages %}
{% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
{% elif pages %}
{% blocktrans %}{{ pages }} pages{% endblocktrans %}
{% endif %}
{% endwith %}
</p> </p>
{% if book.languages %} {% if book.languages %}
{% for language in book.languages %}
<meta itemprop="inLanguage" content="{{ language }}">
{% endfor %}
<p> <p>
{% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %} {% with languages=book.languages|join:", " %}
{% blocktrans %}{{ languages }} language{% endblocktrans %}
{% endwith %}
</p> </p>
{% endif %} {% endif %}
<p> <p>
{% if book.published_date and book.publishers %} {% with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}
{% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} {% if date or book.first_published_date %}
{% elif book.published_date %} <meta
{% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %} itemprop="datePublished"
{% elif book.publishers %} content="{{ book.first_published_date|default:book.published_date|date:'Y-m-d' }}"
{% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %} >
{% endif %} {% endif %}
{% comment %}
@todo The publisher property needs to be an Organization or a Person. Well be using Thing which is the more generic ancestor.
@see https://schema.org/Publisher
{% endcomment %}
{% if book.publishers %}
{% for publisher in book.publishers %}
<meta itemprop="publisher" content="{{ publisher }}">
{% endfor %}
{% endif %}
{% if date and publisher %}
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
{% elif date %}
{% blocktrans %}Published {{ date }}{% endblocktrans %}
{% elif publisher %}
{% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
{% endif %}
{% endwith %}
</p> </p>
{% endspaceless %}

View File

@ -1 +1,17 @@
{% for author in book.authors.all %}<a href="/author/{{ author.id }}" class="author">{{ author.name }}</a>{% if not forloop.last %}, {% endif %}{% endfor %} {% spaceless %}
{% comment %}
@todo The author property needs to be an Organization or a Person. Well be using Thing which is the more generic ancestor.
@see https://schema.org/Author
{% endcomment %}
{% for author in book.authors.all %}
<a
href="/author/{{ author.id }}"
class="author"
itemprop="author"
itemscope
itemtype="https://schema.org/Thing"
><span
itemprop="name"
>{{ author.name }}<span></a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% endspaceless %}

View File

@ -1,13 +1,29 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %}
<div class="cover-container is-{{ size }}"> <div class="cover-container is-{{ size }}">
{% if book.cover %} {% if book.cover %}
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}"> <img
class="book-cover"
src="/images/{{ book.cover }}"
alt="{{ book.alt_text }}"
title="{{ book.alt_text }}"
itemprop="thumbnailUrl"
>
{% else %} {% else %}
<div class="no-cover book-cover"> <div class="no-cover book-cover">
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover"> <img
class="book-cover"
src="/static/images/no_cover.jpg"
alt="{% trans "No cover" %}"
>
<div> <div>
<p>{{ book.alt_text }}</p> <p>{{ book.alt_text }}</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% endspaceless %}

View File

@ -1,13 +1,46 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
<div class="block">
{% if status.status_type == 'Review' or status.status_type == 'Rating' %} {% with status_type=status.status_type %}
<div
class="block"
{% if status_type == 'Review' %}
{% firstof "reviewBody" as body_prop %}
{% firstof 'itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating"' as rating_type %}
{% endif %}
{% if status_type == 'Rating' %}
itemprop="rating"
itemtype="https://schema.org/Rating"
{% endif %}
>
{% if status_type == 'Review' or status_type == 'Rating' %}
<div> <div>
{% if status.name %} {% if status.name %}
<h3 class="title is-5 has-subtitle" dir="auto"> <h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }} {{ status.name|escape }}
</h3> </h3>
{% endif %} {% endif %}
<span
class="is-sr-only"
{{ rating_type }}
>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{% if status_type == 'Rating' %}
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
{% endif %}
</span>
{% include 'snippets/stars.html' with rating=status.rating %} {% include 'snippets/stars.html' with rating=status.rating %}
</div> </div>
{% endif %} {% endif %}
@ -15,15 +48,27 @@
{% if status.content_warning %} {% if status.content_warning %}
<div> <div>
<p>{{ status.content_warning }}</p> <p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %} {% trans "Show more" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div> </div>
{% endif %} {% endif %}
<div{% if status.content_warning %} class="hidden" id="show-status-cw-{{ status.id }}"{% endif %}> <div
{% if status.content_warning %}
id="show-status-cw-{{ status.id }}"
class="hidden"
{% endif %}
>
{% if status.content_warning %} {% if status.content_warning %}
{% trans "Show less" as button_text %} {% trans "Show less" as button_text %}
{% include 'snippets/toggle/close_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %} {% endif %}
{% if status.quote %} {% if status.quote %}
@ -34,17 +79,31 @@
</div> </div>
{% endif %} {% endif %}
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Announce' %} {% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe no_trim=status.content_warning %} {% with full=status.content|safe no_trim=status.content_warning itemprop=body_prop %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% endif %} {% endif %}
{% if status.attachments.exists %} {% if status.attachments.exists %}
<div class="block"> <div class="block">
<div class="columns"> <div class="columns">
{% for attachment in status.attachments.all %} {% for attachment in status.attachments.all %}
<div class="column is-narrow"> <div class="column is-narrow">
<figure class="image is-128x128"> <figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="{% trans 'Open image in new window' %}"> <a
<img src="/images/{{ attachment.image }}"{% if attachment.caption %} alt="{{ attachment.caption }}" title="{{ attachment.caption }}"{% endif %}> href="/images/{{ attachment.image }}"
target="_blank"
aria-label="{% trans 'Open image in new window' %}"
>
<img
src="/images/{{ attachment.image }}"
{% if attachment.caption %}
alt="{{ attachment.caption }}"
title="{{ attachment.caption }}"
{% endif %}
>
</a> </a>
</figure> </figure>
</div> </div>
@ -57,12 +116,22 @@
{% if not hide_book %} {% if not hide_book %}
{% if status.book or status.mention_books.count %} {% if status.book or status.mention_books.count %}
<div class="{% if status.status_type != 'GeneratedNote' %}box has-background-white-bis{% endif %}"> <div
{% if status_type != 'GeneratedNote' %}
class="box has-background-white-bis"
{% endif %}
>
{% if status.book %} {% if status.book %}
{% include 'snippets/status/book_preview.html' with book=status.book %} {% with book=status.book %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% elif status.mention_books.count %} {% elif status.mention_books.count %}
{% include 'snippets/status/book_preview.html' with book=status.mention_books.first %} {% with book=status.mention_books.first %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endwith %}
{% endspaceless %}

View File

@ -1,9 +1,19 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
<a href="{{ status.user.local_path }}"> <span
itemprop="author"
itemscope
itemtype="https://schema.org/Person"
>
<a
href="{{ status.user.local_path }}"
itemprop="url"
>
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %} {% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
{{ status.user.display_name }}
<span itemprop="name">{{ status.user.display_name }}</span>
</a> </a>
</span>
{% if status.status_type == 'GeneratedNote' %} {% if status.status_type == 'GeneratedNote' %}
{{ status.content | safe }} {{ status.content | safe }}

View File

@ -1,10 +1,10 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
{% with 0|uuid as uuid %} {% with 0|uuid as uuid %}
{% if full %} {% if full %}
{% with full|to_markdown|safe as full %} {% with full|to_markdown|safe as full %}
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %} {% with full|to_markdown|safe|truncatewords_html:60 as trimmed %}
{% if not no_trim and trimmed != full %} {% if not no_trim and trimmed != full %}
<div id="hide-full-{{ uuid }}"> <div id="hide-full-{{ uuid }}">
@ -19,7 +19,12 @@
</div> </div>
<div id="full-{{ uuid }}" class="hidden"> <div id="full-{{ uuid }}" class="hidden">
<div class="content"> <div class="content">
<div dir="auto">{{ full }}</div> <div
dir="auto"
{% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
>
{{ full }}
</div>
<div> <div>
{% trans "Show less" as button_text %} {% trans "Show less" as button_text %}
@ -29,12 +34,16 @@
</div> </div>
{% else %} {% else %}
<div class="content"> <div class="content">
<div dir="auto">{{ full }}</div> <div
dir="auto"
{% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
>
{{ full }}
</div>
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endspaceless %}

View File

@ -0,0 +1 @@
from . import *

View File

@ -0,0 +1,133 @@
""" tests incoming activities"""
import json
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
# pylint: disable=too-many-public-methods
class Inbox(TestCase):
""" readthrough tests """
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",
"mouseword",
local=True,
localname="mouse",
)
local_user.remote_id = "https://example.com/user/mouse"
local_user.save(broadcast=False)
self.create_json = {
"id": "hi",
"type": "Create",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {},
}
models.SiteSettings.objects.create()
def test_inbox_invalid_get(self):
""" shouldn't try to handle if the user is not found """
result = self.client.get("/inbox", content_type="application/json")
self.assertIsInstance(result, HttpResponseNotAllowed)
def test_inbox_invalid_user(self):
""" shouldn't try to handle if the user is not found """
result = self.client.post(
"/user/bleh/inbox",
'{"type": "Test", "object": "exists"}',
content_type="application/json",
)
self.assertIsInstance(result, HttpResponseNotFound)
def test_inbox_invalid_bad_signature(self):
""" bad request for invalid signature """
with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid:
mock_valid.return_value = False
result = self.client.post(
"/user/mouse/inbox",
'{"type": "Announce", "object": "exists"}',
content_type="application/json",
)
self.assertEqual(result.status_code, 401)
def test_inbox_invalid_bad_signature_delete(self):
""" invalid signature for Delete is okay though """
with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid:
mock_valid.return_value = False
result = self.client.post(
"/user/mouse/inbox",
'{"type": "Delete", "object": "exists"}',
content_type="application/json",
)
self.assertEqual(result.status_code, 200)
def test_inbox_unknown_type(self):
""" never heard of that activity type, don't have a handler for it """
with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid:
result = self.client.post(
"/inbox",
'{"type": "Fish", "object": "exists"}',
content_type="application/json",
)
mock_valid.return_value = True
self.assertIsInstance(result, HttpResponseNotFound)
def test_inbox_success(self):
""" a known type, for which we start a task """
activity = self.create_json
activity["object"] = {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
}
with patch("bookwyrm.views.inbox.has_valid_signature") as mock_valid:
mock_valid.return_value = True
with patch("bookwyrm.views.inbox.activity_task.delay"):
result = self.client.post(
"/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))

View File

@ -0,0 +1,156 @@
""" tests incoming activities"""
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxActivities(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"):
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_handle_add_book_to_shelf(self):
""" shelving a book """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
shelf.save()
activity = {
"id": "https://bookwyrm.social/shelfbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
self.assertEqual(shelf.books.first(), book)
@responses.activate
def test_handle_add_book_to_list(self):
""" listing a book """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
responses.add(
responses.GET,
"https://bookwyrm.social/user/mouse/list/to-read",
json={
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
},
)
activity = {
"id": "https://bookwyrm.social/listbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://bookwyrm.social/user/mouse/list/to-read",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
booklist = models.List.objects.get()
self.assertEqual(booklist.name, "Test List")
self.assertEqual(booklist.books.first(), book)
@responses.activate
def test_handle_tag_book(self):
""" listing a book """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
responses.add(
responses.GET,
"https://www.example.com/tag/cool-tag",
json={
"id": "https://1b1a78582461.ngrok.io/tag/tag",
"type": "OrderedCollection",
"totalItems": 0,
"first": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
"last": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
"name": "cool tag",
"@context": "https://www.w3.org/ns/activitystreams",
},
)
activity = {
"id": "https://bookwyrm.social/listbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://www.example.com/tag/cool-tag",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
tag = models.Tag.objects.get()
self.assertFalse(models.List.objects.exists())
self.assertEqual(tag.name, "cool tag")
self.assertEqual(tag.books.first(), book)

View File

@ -0,0 +1,190 @@
""" tests incoming activities"""
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxActivities(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"):
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",
)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
self.status = models.Status.objects.create(
user=self.local_user,
content="Test status",
remote_id="https://example.com/status/1",
)
self.create_json = {
"id": "hi",
"type": "Create",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {},
}
models.SiteSettings.objects.create()
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
def test_handle_boost(self, _):
""" boost a status """
self.assertEqual(models.Notification.objects.count(), 0)
activity = {
"type": "Announce",
"id": "%s/boost" % self.status.remote_id,
"actor": self.remote_user.remote_id,
"object": self.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",
}
with patch("bookwyrm.models.status.Status.ignore_activity") as discarder:
discarder.return_value = False
views.inbox.activity_task(activity)
boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, self.status)
notification = models.Notification.objects.get()
self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_status, self.status)
@responses.activate
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
def test_handle_boost_remote_status(self, redis_mock):
""" boost a status """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
self.assertEqual(models.Notification.objects.count(), 0)
activity = {
"type": "Announce",
"id": "%s/boost" % self.status.remote_id,
"actor": self.remote_user.remote_id,
"object": "https://remote.com/status/1",
"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,
"https://remote.com/status/1",
json={
"id": "https://remote.com/status/1",
"type": "Comment",
"published": "2021-04-05T18:04:59.735190+00:00",
"attributedTo": self.remote_user.remote_id,
"content": "<p>a comment</p>",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://b875df3d118b.ngrok.io/user/mouse/followers"],
"inReplyTo": "",
"inReplyToBook": book.remote_id,
"summary": "",
"tag": [],
"sensitive": False,
"@context": "https://www.w3.org/ns/activitystreams",
},
)
with patch("bookwyrm.models.status.Status.ignore_activity") as discarder:
discarder.return_value = False
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status.remote_id, "https://remote.com/status/1")
self.assertEqual(boost.boosted_status.comment.status_type, "Comment")
self.assertEqual(boost.boosted_status.comment.book, book)
@responses.activate
def test_handle_discarded_boost(self):
""" test a boost of a mastodon status that will be discarded """
status = models.Status(
content="hi",
user=self.remote_user,
)
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
status.save(broadcast=False)
activity = {
"type": "Announce",
"id": "http://www.faraway.com/boost/12",
"actor": self.remote_user.remote_id,
"object": status.remote_id,
}
responses.add(
responses.GET, status.remote_id, json=status.to_activity(), status=200
)
views.inbox.activity_task(activity)
self.assertEqual(models.Boost.objects.count(), 0)
def test_handle_unboost(self):
""" undo a boost """
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
boost = models.Boost.objects.create(
boosted_status=self.status, user=self.remote_user
)
activity = {
"type": "Undo",
"actor": "hi",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {
"type": "Announce",
"id": boost.remote_id,
"actor": self.remote_user.remote_id,
"object": self.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",
},
}
with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
self.assertFalse(models.Boost.objects.exists())
def test_handle_unboost_unknown_boost(self):
""" undo a boost """
activity = {
"type": "Undo",
"actor": "hi",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {
"type": "Announce",
"id": "http://fake.com/unknown/boost",
"actor": self.remote_user.remote_id,
"object": self.status.remote_id,
},
}
views.inbox.activity_task(activity)

View File

@ -0,0 +1,98 @@
""" tests incoming activities"""
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxBlock(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"):
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_handle_blocks(self):
""" create a "block" database entry from an activity """
self.local_user.followers.add(self.remote_user)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
models.UserFollowRequest.objects.create(
user_subject=self.local_user, user_object=self.remote_user
)
self.assertTrue(models.UserFollows.objects.exists())
self.assertTrue(models.UserFollowRequest.objects.exists())
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/9e1f41ac-9ddd-4159",
"type": "Block",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse",
}
with patch(
"bookwyrm.activitystreams.ActivityStream.remove_user_statuses"
) as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
views.inbox.activity_task(activity)
block = models.UserBlocks.objects.get()
self.assertEqual(block.user_subject, self.remote_user)
self.assertEqual(block.user_object, self.local_user)
self.assertEqual(block.remote_id, "https://example.com/9e1f41ac-9ddd-4159")
self.assertFalse(models.UserFollows.objects.exists())
self.assertFalse(models.UserFollowRequest.objects.exists())
def test_handle_unblock(self):
""" unblock a user """
self.remote_user.blocks.add(self.local_user)
block = models.UserBlocks.objects.get()
block.remote_id = "https://example.com/9e1f41ac-9ddd-4159"
block.save()
self.assertEqual(block.user_subject, self.remote_user)
self.assertEqual(block.user_object, self.local_user)
activity = {
"type": "Undo",
"actor": "hi",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/9e1f41ac-9ddd-4159",
"type": "Block",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse",
},
}
with patch(
"bookwyrm.activitystreams.ActivityStream.add_user_statuses"
) as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
self.assertFalse(models.UserBlocks.objects.exists())

View File

@ -0,0 +1,151 @@
""" tests incoming activities"""
import json
import pathlib
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxActivities(TestCase):
""" readthrough tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
self.status = models.Status.objects.create(
user=self.local_user,
content="Test status",
remote_id="https://example.com/status/1",
)
with patch("bookwyrm.models.user.set_remote_server.delay"):
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",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {},
}
models.SiteSettings.objects.create()
def test_handle_create_status(self):
""" the "it justs works" mode """
self.assertEqual(models.Status.objects.count(), 1)
datafile = pathlib.Path(__file__).parent.joinpath(
"../../data/ap_quotation.json"
)
status_data = json.loads(datafile.read_bytes())
models.Edition.objects.create(
title="Test Book", remote_id="https://example.com/book/1"
)
activity = self.create_json
activity["object"] = status_data
with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
status = models.Quotation.objects.get()
self.assertEqual(
status.remote_id, "https://example.com/user/mouse/quotation/13"
)
self.assertEqual(status.quote, "quote body")
self.assertEqual(status.content, "commentary")
self.assertEqual(status.user, self.local_user)
self.assertEqual(models.Status.objects.count(), 2)
# while we're here, lets ensure we avoid dupes
views.inbox.activity_task(activity)
self.assertEqual(models.Status.objects.count(), 2)
def test_handle_create_status_remote_note_with_mention(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).exists()
)
datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_note.json")
status_data = json.loads(datafile.read_bytes())
activity = self.create_json
activity["object"] = status_data
with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
status = models.Status.objects.last()
self.assertEqual(status.content, "test content in note")
self.assertEqual(status.mention_users.first(), self.local_user)
self.assertTrue(
models.Notification.objects.filter(user=self.local_user).exists()
)
self.assertEqual(models.Notification.objects.get().notification_type, "MENTION")
def test_handle_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))
datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_note.json")
status_data = json.loads(datafile.read_bytes())
del status_data["tag"]
status_data["inReplyTo"] = self.status.remote_id
activity = self.create_json
activity["object"] = status_data
with patch("bookwyrm.activitystreams.ActivityStream.add_status") as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
status = models.Status.objects.last()
self.assertEqual(status.content, "test content in note")
self.assertEqual(status.reply_parent, self.status)
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):
""" a new list """
activity = self.create_json
activity["object"] = {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
book_list = models.List.objects.get()
self.assertEqual(book_list.name, "Test List")
self.assertEqual(book_list.curation, "curated")
self.assertEqual(book_list.description, "summary text")
self.assertEqual(book_list.remote_id, "https://example.com/list/22")

View File

@ -0,0 +1,106 @@
""" tests incoming activities"""
from datetime import datetime
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxActivities(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"):
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",
)
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
self.status = models.Status.objects.create(
user=self.remote_user,
content="Test status",
remote_id="https://example.com/status/1",
)
self.create_json = {
"id": "hi",
"type": "Create",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {},
}
models.SiteSettings.objects.create()
def test_handle_delete_status(self):
""" remove a status """
self.assertFalse(self.status.deleted)
activity = {
"type": "Delete",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"id": "%s/activity" % self.status.remote_id,
"actor": self.remote_user.remote_id,
"object": {"id": self.status.remote_id, "type": "Tombstone"},
}
with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
# deletion doens't remove the status, it turns it into a tombstone
status = models.Status.objects.get()
self.assertTrue(status.deleted)
self.assertIsInstance(status.deleted_date, datetime)
def test_handle_delete_status_notifications(self):
""" remove a status with related notifications """
models.Notification.objects.create(
related_status=self.status,
user=self.local_user,
notification_type="MENTION",
)
# this one is innocent, don't delete it
notif = models.Notification.objects.create(
user=self.local_user, notification_type="MENTION"
)
self.assertFalse(self.status.deleted)
self.assertEqual(models.Notification.objects.count(), 2)
activity = {
"type": "Delete",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"id": "%s/activity" % self.status.remote_id,
"actor": self.remote_user.remote_id,
"object": {"id": self.status.remote_id, "type": "Tombstone"},
}
with patch(
"bookwyrm.activitystreams.ActivityStream.remove_object_from_related_stores"
) as redis_mock:
views.inbox.activity_task(activity)
self.assertTrue(redis_mock.called)
# deletion doens't remove the status, it turns it into a tombstone
status = models.Status.objects.get()
self.assertTrue(status.deleted)
self.assertIsInstance(status.deleted_date, datetime)
# notifications should be truly deleted
self.assertEqual(models.Notification.objects.count(), 1)
self.assertEqual(models.Notification.objects.get(), notif)

View File

@ -0,0 +1,205 @@
""" tests incoming activities"""
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxRelationships(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"):
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_handle_follow(self):
""" remote user wants to follow local user """
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",
}
self.assertFalse(models.UserFollowRequest.objects.exists())
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.inbox.activity_task(activity)
self.assertEqual(mock.call_count, 1)
# notification created
notification = models.Notification.objects.get()
self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.notification_type, "FOLLOW")
# the request should have been deleted
self.assertFalse(models.UserFollowRequest.objects.exists())
# the follow relationship should exist
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):
""" needs approval before following """
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",
}
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.inbox.activity_task(activity)
# notification created
notification = models.Notification.objects.get()
self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.notification_type, "FOLLOW_REQUEST")
# the request should exist
request = models.UserFollowRequest.objects.get()
self.assertEqual(request.user_subject, self.remote_user)
self.assertEqual(request.user_object, self.local_user)
# the follow relationship should not exist
follow = models.UserFollows.objects.all()
self.assertEqual(list(follow), [])
def test_handle_undo_follow_request(self):
""" the requester cancels a follow request """
self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
request = models.UserFollowRequest.objects.create(
user_subject=self.remote_user, user_object=self.local_user
)
self.assertTrue(self.local_user.follower_requests.exists())
activity = {
"type": "Undo",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"actor": self.remote_user.remote_id,
"@context": "https://www.w3.org/ns/activitystreams",
"object": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": request.remote_id,
"type": "Follow",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse",
},
}
views.inbox.activity_task(activity)
self.assertFalse(self.local_user.follower_requests.exists())
def test_handle_unfollow(self):
""" remove a relationship """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
rel = models.UserFollows.objects.create(
user_subject=self.remote_user, user_object=self.local_user
)
activity = {
"type": "Undo",
"id": "bleh",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"actor": self.remote_user.remote_id,
"@context": "https://www.w3.org/ns/activitystreams",
"object": {
"id": rel.remote_id,
"type": "Follow",
"actor": "https://example.com/users/rat",
"object": "https://example.com/user/mouse",
},
}
self.assertEqual(self.remote_user, self.local_user.followers.first())
views.inbox.activity_task(activity)
self.assertIsNone(self.local_user.followers.first())
def test_handle_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(
user_subject=self.local_user, user_object=self.remote_user
)
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/users/rat/follows/123#accepts",
"type": "Accept",
"actor": "https://example.com/users/rat",
"object": {
"id": rel.remote_id,
"type": "Follow",
"actor": "https://example.com/user/mouse",
"object": "https://example.com/users/rat",
},
}
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
views.inbox.activity_task(activity)
# request should be deleted
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
# relationship should be created
follows = self.remote_user.followers
self.assertEqual(follows.count(), 1)
self.assertEqual(follows.first(), self.local_user)
def test_handle_follow_reject(self):
""" turn down a follow request """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
rel = models.UserFollowRequest.objects.create(
user_subject=self.local_user, user_object=self.remote_user
)
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/users/rat/follows/123#accepts",
"type": "Reject",
"actor": "https://example.com/users/rat",
"object": {
"id": rel.remote_id,
"type": "Follow",
"actor": "https://example.com/user/mouse",
"object": "https://example.com/users/rat",
},
}
self.assertEqual(models.UserFollowRequest.objects.count(), 1)
views.inbox.activity_task(activity)
# request should be deleted
self.assertFalse(models.UserFollowRequest.objects.exists())
self.assertFalse(self.remote_user.followers.exists())

View File

@ -0,0 +1,110 @@
""" tests incoming activities"""
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxActivities(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"):
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",
)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
self.status = models.Status.objects.create(
user=self.local_user,
content="Test status",
remote_id="https://example.com/status/1",
)
self.create_json = {
"id": "hi",
"type": "Create",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {},
}
models.SiteSettings.objects.create()
def test_handle_favorite(self):
""" fav a status """
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/fav/1",
"actor": "https://example.com/users/rat",
"type": "Like",
"published": "Mon, 25 May 2020 19:31:20 GMT",
"object": self.status.remote_id,
}
views.inbox.activity_task(activity)
fav = models.Favorite.objects.get(remote_id="https://example.com/fav/1")
self.assertEqual(fav.status, self.status)
self.assertEqual(fav.remote_id, "https://example.com/fav/1")
self.assertEqual(fav.user, self.remote_user)
def test_ignore_favorite(self):
""" don't try to save an unknown status """
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/fav/1",
"actor": "https://example.com/users/rat",
"type": "Like",
"published": "Mon, 25 May 2020 19:31:20 GMT",
"object": "https://unknown.status/not-found",
}
views.inbox.activity_task(activity)
self.assertFalse(models.Favorite.objects.exists())
def test_handle_unfavorite(self):
""" fav a status """
activity = {
"id": "https://example.com/fav/1#undo",
"type": "Undo",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"actor": self.remote_user.remote_id,
"object": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/fav/1",
"actor": "https://example.com/users/rat",
"type": "Like",
"published": "Mon, 25 May 2020 19:31:20 GMT",
"object": self.status.remote_id,
},
}
models.Favorite.objects.create(
status=self.status,
user=self.remote_user,
remote_id="https://example.com/fav/1",
)
self.assertEqual(models.Favorite.objects.count(), 1)
views.inbox.activity_task(activity)
self.assertEqual(models.Favorite.objects.count(), 0)

View File

@ -0,0 +1,61 @@
""" tests incoming activities"""
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxActivities(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
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_handle_unshelve_book(self):
""" remove a book from a shelf """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
shelf.save()
shelfbook = models.ShelfBook.objects.create(
user=self.remote_user, shelf=shelf, book=book
)
self.assertEqual(shelf.books.first(), book)
self.assertEqual(shelf.books.count(), 1)
activity = {
"id": shelfbook.remote_id,
"type": "Remove",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
self.assertFalse(shelf.books.exists())

View File

@ -0,0 +1,149 @@
""" tests incoming activities"""
import json
import pathlib
from unittest.mock import patch
from django.test import TestCase
from bookwyrm import models, views
# pylint: disable=too-many-public-methods
class InboxUpdate(TestCase):
""" inbox tests """
def setUp(self):
""" basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
self.create_json = {
"id": "hi",
"type": "Create",
"actor": "hi",
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"object": {},
}
models.SiteSettings.objects.create()
def test_handle_update_list(self):
""" a new list """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
book_list = models.List.objects.create(
name="hi", remote_id="https://example.com/list/22", user=self.local_user
)
activity = {
"type": "Update",
"to": [],
"cc": [],
"actor": "hi",
"id": "sdkjf",
"object": {
"id": "https://example.com/list/22",
"type": "BookList",
"totalItems": 1,
"first": "https://example.com/list/22?page=1",
"last": "https://example.com/list/22?page=1",
"name": "Test List",
"owner": "https://example.com/user/mouse",
"to": ["https://www.w3.org/ns/activitystreams#Public"],
"cc": ["https://example.com/user/mouse/followers"],
"summary": "summary text",
"curation": "curated",
"@context": "https://www.w3.org/ns/activitystreams",
},
}
views.inbox.activity_task(activity)
book_list.refresh_from_db()
self.assertEqual(book_list.name, "Test List")
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_handle_update_user(self):
""" update an existing user """
# we only do this with remote users
self.local_user.local = False
self.local_user.save()
datafile = pathlib.Path(__file__).parent.joinpath("../../data/ap_user.json")
userdata = json.loads(datafile.read_bytes())
del userdata["icon"]
self.assertIsNone(self.local_user.name)
views.inbox.activity_task(
{
"type": "Update",
"to": [],
"cc": [],
"actor": "hi",
"id": "sdkjf",
"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")
self.assertTrue(user.discoverable)
def test_handle_update_edition(self):
""" update an existing edition """
datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_edition.json")
bookdata = json.loads(datafile.read_bytes())
models.Work.objects.create(
title="Test Work", remote_id="https://bookwyrm.social/book/5988"
)
book = models.Edition.objects.create(
title="Test Book", remote_id="https://bookwyrm.social/book/5989"
)
del bookdata["authors"]
self.assertEqual(book.title, "Test Book")
with patch("bookwyrm.activitypub.base_activity.set_related_field.delay"):
views.inbox.activity_task(
{
"type": "Update",
"to": [],
"cc": [],
"actor": "hi",
"id": "sdkjf",
"object": bookdata,
}
)
book = models.Edition.objects.get(id=book.id)
self.assertEqual(book.title, "Piranesi")
def test_handle_update_work(self):
""" update an existing edition """
datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_work.json")
bookdata = json.loads(datafile.read_bytes())
book = models.Work.objects.create(
title="Test Book", remote_id="https://bookwyrm.social/book/5988"
)
del bookdata["authors"]
self.assertEqual(book.title, "Test Book")
with patch("bookwyrm.activitypub.base_activity.set_related_field.delay"):
views.inbox.activity_task(
{
"type": "Update",
"to": [],
"cc": [],
"actor": "hi",
"id": "sdkjf",
"object": bookdata,
}
)
book = models.Work.objects.get(id=book.id)
self.assertEqual(book.title, "Piranesi")

View File

@ -112,6 +112,9 @@ class ViewsHelpers(TestCase):
result = views.helpers.handle_remote_webfinger("mouse@local.com") result = views.helpers.handle_remote_webfinger("mouse@local.com")
self.assertEqual(result, self.local_user) self.assertEqual(result, self.local_user)
result = views.helpers.handle_remote_webfinger("mOuSe@loCal.cOm")
self.assertEqual(result, self.local_user)
@responses.activate @responses.activate
def test_load_user(self, _): def test_load_user(self, _):
""" find a remote user using webfinger """ """ find a remote user using webfinger """

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
""" the good stuff! the books! """ """ the good stuff! the books! """
from uuid import uuid4 from uuid import uuid4
from dateutil.parser import parse as dateparse
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import SearchRank, SearchVector from django.contrib.postgres.search import SearchRank, SearchVector
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@ -10,6 +11,7 @@ from django.db.models import Avg, Q
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views import View from django.views import View
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@ -172,6 +174,20 @@ class EditBook(View):
data["confirm_mode"] = True data["confirm_mode"] = True
# this isn't preserved because it isn't part of the form obj # this isn't preserved because it isn't part of the form obj
data["remove_authors"] = request.POST.getlist("remove_authors") data["remove_authors"] = request.POST.getlist("remove_authors")
# 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, ValueError):
pass
try:
formcopy["published_date"] = dateparse(formcopy["published_date"])
except (MultiValueDictKeyError, ValueError):
pass
data["form"].data = formcopy
return TemplateResponse(request, "book/edit_book.html", data) return TemplateResponse(request, "book/edit_book.html", data)
remove_authors = request.POST.getlist("remove_authors") remove_authors = request.POST.getlist("remove_authors")

View File

@ -124,7 +124,7 @@ def handle_remote_webfinger(query):
return None return None
try: try:
user = models.User.objects.get(username=query) user = models.User.objects.get(username__iexact=query)
except models.User.DoesNotExist: except models.User.DoesNotExist:
url = "https://%s/.well-known/webfinger?resource=acct:%s" % (domain, query) url = "https://%s/.well-known/webfinger?resource=acct:%s" % (domain, query)
try: try:

View File

@ -20,7 +20,8 @@ EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env("EMAIL_PORT") EMAIL_PORT = env("EMAIL_PORT")
EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env("EMAIL_USE_TLS") EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS")
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
celery==4.4.2 celery==4.4.2
Django==3.1.6 Django==3.1.8
django-model-utils==4.0.0 django-model-utils==4.0.0
environs==7.2.0 environs==7.2.0
flower==0.9.4 flower==0.9.4