Merge branch 'main' into smaller-statuses-dense-cards

This commit is contained in:
Joachim 2021-04-24 20:07:13 +02:00 committed by GitHub
commit e06154c457
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 1904 additions and 2510 deletions

View file

@ -11,6 +11,7 @@ class Book(ActivityObject):
""" serializes an edition or work, abstract """ """ serializes an edition or work, abstract """
title: str title: str
lastEditedBy: str = None
sortTitle: str = "" sortTitle: str = ""
subtitle: str = "" subtitle: str = ""
description: str = "" description: str = ""
@ -64,6 +65,7 @@ class Author(ActivityObject):
""" author of a book """ """ author of a book """
name: str name: str
lastEditedBy: str = None
born: str = None born: str = None
died: str = None died: str = None
aliases: List[str] = field(default_factory=lambda: []) aliases: List[str] = field(default_factory=lambda: [])

View file

@ -176,6 +176,7 @@ class Remove(Add):
def action(self): def action(self):
""" find and remove the activity object """ """ find and remove the activity object """
obj = self.object.to_model(save=False, allow_create=False) obj = self.object.to_model(save=False, allow_create=False)
if obj:
obj.delete() obj.delete()

View file

@ -156,14 +156,6 @@ class UserGroupForm(CustomForm):
fields = ["groups"] fields = ["groups"]
class TagForm(CustomForm):
class Meta:
model = models.Tag
fields = ["name"]
help_texts = {f: None for f in fields}
labels = {"name": "Add a tag"}
class CoverForm(CustomForm): class CoverForm(CustomForm):
class Meta: class Meta:
model = models.Book model = models.Book

View file

@ -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,
),
),
]

View file

@ -0,0 +1,35 @@
# Generated by Django 3.1.8 on 2021-04-23 01:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0069_auto_20210422_1604"),
]
operations = [
migrations.AlterUniqueTogether(
name="usertag",
unique_together=None,
),
migrations.RemoveField(
model_name="usertag",
name="book",
),
migrations.RemoveField(
model_name="usertag",
name="tag",
),
migrations.RemoveField(
model_name="usertag",
name="user",
),
migrations.DeleteModel(
name="Tag",
),
migrations.DeleteModel(
name="UserTag",
),
]

View file

@ -17,8 +17,6 @@ from .favorite import Favorite
from .notification import Notification from .notification import Notification
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .tag import Tag, UserTag
from .user import User, KeyPair, AnnualGoal from .user import User, KeyPair, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment from .report import Report, ReportComment

View file

@ -148,14 +148,18 @@ class ActivitypubMixin:
mentions = self.recipients if hasattr(self, "recipients") else [] mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes # 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 # unless it's a dm, all the followers should receive the activity
if privacy != "direct": if privacy != "direct":
# we will send this out to a subset of all remote users # we will send this out to a subset of all remote users
queryset = user_model.viewer_aware_objects(user).filter( queryset = (
user_model.viewer_aware_objects(user)
.filter(
local=False, local=False,
) )
.distinct()
)
# filter users first by whether they're using the desired software # filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers # this lets us send book updates only to other bw servers
if software: if software:
@ -175,7 +179,7 @@ class ActivitypubMixin:
"inbox", flat=True "inbox", flat=True
) )
recipients += list(shared_inboxes) + list(inboxes) recipients += list(shared_inboxes) + list(inboxes)
return recipients return list(set(recipients))
def to_activity_dataclass(self): def to_activity_dataclass(self):
""" convert from a model to an activity """ """ convert from a model to an activity """
@ -200,7 +204,9 @@ class ObjectMixin(ActivitypubMixin):
created = created or not bool(self.id) created = created or not bool(self.id)
# first off, we want to save normally no matter what # first off, we want to save normally no matter what
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not broadcast: if not broadcast or (
hasattr(self, "status_type") and self.status_type == "Announce"
):
return return
# this will work for objects owned by a user (lists, shelves) # this will work for objects owned by a user (lists, shelves)

View file

@ -26,7 +26,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
max_length=255, blank=True, null=True, deduplication_field=True 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: class Meta:
""" can't initialize this model, that wouldn't make sense """ """ can't initialize this model, that wouldn't make sense """

View file

@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
return [i.remote_id for i in value.all()] return [i.remote_id for i in value.all()]
def field_from_activity(self, value): def field_from_activity(self, value):
items = []
if value is None or value is MISSING: 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: for remote_id in value:
try: try:
validate_remote_id(remote_id) validate_remote_id(remote_id)

View file

@ -101,12 +101,15 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
def save(self, *args, broadcast=True, **kwargs): def save(self, *args, broadcast=True, **kwargs):
""" make sure the follow or block relationship doesn't already exist """ """ 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( if UserFollows.objects.filter(
user_subject=self.user_subject, user_subject=self.user_subject,
user_object=self.user_object, user_object=self.user_object,
).exists(): ).exists():
raise IntegrityError() self.accept(broadcast_only=True)
return
# blocking in either direction is a no-go # blocking in either direction is a no-go
if UserBlocks.objects.filter( if UserBlocks.objects.filter(
Q( Q(
@ -141,9 +144,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
""" get id for sending an accept or reject of a local user """ """ get id for sending an accept or reject of a local user """
base_path = self.user_object.remote_id base_path = self.user_object.remote_id
return "%s#%s/%d" % (base_path, status, self.id) return "%s#%s/%d" % (base_path, status, self.id or 0)
def accept(self): def accept(self, broadcast_only=False):
""" turn this request into the real deal""" """ turn this request into the real deal"""
user = self.user_object user = self.user_object
if not self.user_subject.local: if not self.user_subject.local:
@ -153,6 +156,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
object=self.to_activity(), object=self.to_activity(),
).serialize() ).serialize()
self.broadcast(activity, user) self.broadcast(activity, user)
if broadcast_only:
return
with transaction.atomic(): with transaction.atomic():
UserFollows.from_request(self) UserFollows.from_request(self)
self.delete() self.delete()

View file

@ -351,6 +351,16 @@ class Boost(ActivityMixin, Status):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
""" save and notify """ """ save and notify """
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
if (
Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
.exclude(id=self.id)
.exists()
):
return
super().save(*args, **kwargs) super().save(*args, **kwargs)
if not self.boosted_status.user.local or self.boosted_status.user == self.user: if not self.boosted_status.user.local or self.boosted_status.user == self.user:
return return

View file

@ -1,63 +0,0 @@
""" models for storing different kinds of Activities """
import urllib.parse
from django.apps import apps
from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
class Tag(OrderedCollectionMixin, BookWyrmModel):
""" freeform tags for books """
name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100)
@property
def books(self):
""" count of books associated with this tag """
edition_model = apps.get_model("bookwyrm.Edition", require_ready=True)
return (
edition_model.objects.filter(usertag__tag__identifier=self.identifier)
.order_by("-created_date")
.distinct()
)
collection_queryset = books
def get_remote_id(self):
""" tag should use identifier not id in remote_id """
base_path = "https://%s" % DOMAIN
return "%s/tag/%s" % (base_path, self.identifier)
def save(self, *args, **kwargs):
""" create a url-safe lookup key for the tag """
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class UserTag(CollectionItemMixin, BookWyrmModel):
""" an instance of a tag on a book by a user """
user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
book = fields.ForeignKey(
"Edition", on_delete=models.PROTECT, activitypub_field="object"
)
tag = fields.ForeignKey("Tag", on_delete=models.PROTECT, activitypub_field="target")
activity_serializer = activitypub.Add
object_field = "book"
collection_field = "tag"
class Meta:
""" unqiueness constraint """
unique_together = ("user", "book", "tag")

View file

@ -1,6 +1,5 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-padding-top: 20%;
} }
body { body {
@ -110,6 +109,13 @@ body {
} }
} }
/** Stars
******************************************************************************/
.stars {
white-space: nowrap;
}
/** Stars in a review form /** Stars in a review form
* *
* Specificity makes hovering taking over checked inputs. * Specificity makes hovering taking over checked inputs.

View file

@ -23,7 +23,7 @@
<div class="dropdown-menu"> <div class="dropdown-menu">
<ul <ul
id="menu-options-{{ uuid }}" id="menu-options-{{ uuid }}"
class="dropdown-content" class="dropdown-content p-0 is-clipped"
role="menu" role="menu"
> >
{% block dropdown-list %}{% endblock %} {% block dropdown-list %}{% endblock %}

View file

@ -1,7 +1,7 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{% get_lang %}">
<head> <head>
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title> <title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">

View file

@ -51,7 +51,7 @@
{% for status in report.statuses.select_subclasses.all %} {% for status in report.statuses.select_subclasses.all %}
<li> <li>
{% if status.deleted %} {% if status.deleted %}
<em>{% trans "Statuses has been deleted" %}</em> <em>{% trans "Status has been deleted" %}</em>
{% else %} {% else %}
{% include 'snippets/status/status.html' with status=status moderation_mode=True %} {% include 'snippets/status/status.html' with status=status moderation_mode=True %}
{% endif %} {% endif %}

View file

@ -1,7 +1,8 @@
{% load i18n %} {% load i18n %}
{% load bookwyrm_tags %}
{% if book.authors %} {% if book.authors %}
{% blocktrans with path=book.local_path title=book.title %}<a href="{{ path }}">{{ title }}</a> by {% endblocktrans %}{% include 'snippets/authors.html' with book=book %} {% blocktrans with path=book.local_path title=book|title %}<a href="{{ path }}">{{ title }}</a> by {% endblocktrans %}{% include 'snippets/authors.html' with book=book %}
{% else %} {% else %}
<a href="{{ book.local_path }}">{{ book.title }}</a> <a href="{{ book.local_path }}">{{ book|title }}</a>
{% endif %} {% endif %}

View file

@ -6,14 +6,16 @@
<input type="hidden" name="user" value="{{ request.user.id }}"> <input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}"> <input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}">
{% if type == 'review' %} {% if type == 'review' %}
<div class="control"> <div class="field">
<label class="label" for="id_name_{{ book.id }}_{{ type }}">{% trans "Title" %}:</label> <label class="label" for="id_name_{{ book.id }}_{{ type }}">{% trans "Title" %}:</label>
<div class="control">
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}"> <input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}">
</div> </div>
</div>
{% endif %} {% endif %}
<div class="control"> <div class="field">
{% if type != 'reply' and type != 'direct' %} {% if type != 'reply' and type != 'direct' %}
<label class="label" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}"> <label class="label{% if type == 'review' %} mb-0{% endif %}" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">
{% if type == 'comment' %} {% if type == 'comment' %}
{% trans "Comment:" %} {% trans "Comment:" %}
{% elif type == 'quotation' %} {% elif type == 'quotation' %}
@ -25,28 +27,37 @@
{% endif %} {% endif %}
{% if type == 'review' %} {% if type == 'review' %}
<fieldset> <fieldset class="mb-1">
<legend class="is-sr-only">{% trans "Rating" %}</legend> <legend class="is-sr-only">{% trans "Rating" %}</legend>
{% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' default_rating=draft.rating %} {% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' default_rating=draft.rating %}
</fieldset> </fieldset>
{% endif %} {% endif %}
<div class="control">
{% if type == 'quotation' %} {% if type == 'quotation' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea> <textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
{% elif type == 'reply' %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" aria-label="{% trans 'Reply' %}" required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
{% else %} {% else %}
{% include 'snippets/content_warning_field.html' with parent_status=status %} {% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" {% if type == 'reply' %} aria-label="Reply"{% endif %} required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea> <textarea name="content" class="textarea" id="id_content_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.content|default:'' }}</textarea>
{% endif %} {% endif %}
</div> </div>
</div>
{# Supplemental fields #}
{% if type == 'quotation' %} {% if type == 'quotation' %}
<div class="control"> <div class="field">
<label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label> <label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label>
{% include 'snippets/content_warning_field.html' with parent_status=status %} {% include 'snippets/content_warning_field.html' with parent_status=status %}
<textarea name="content" class="textarea is-small" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea> <div class="control">
<textarea name="content" class="textarea" rows="3" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea>
</div>
</div> </div>
{% elif type == 'comment' %} {% elif type == 'comment' %}
<div class="control"> <div>
{% active_shelf book as active_shelf %} {% active_shelf book as active_shelf %}
{% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %} {% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %}
@ -58,13 +69,15 @@
<div class="control"> <div class="control">
<input aria-label="{% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress-{{ uuid }}"> <input aria-label="{% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress-{{ uuid }}">
</div> </div>
<div class="control select"> <div class="control">
<div class="select">
<select name="progress_mode" aria-label="Progress mode"> <select name="progress_mode" aria-label="Progress mode">
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option> <option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option> <option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
</select> </select>
</div> </div>
</div> </div>
</div>
{% if readthrough.progress_mode == 'PG' and book.pages %} {% if readthrough.progress_mode == 'PG' and book.pages %}
<p class="help">{% blocktrans with pages=book.pages %}of {{ pages }} pages{% endblocktrans %}</p> <p class="help">{% blocktrans with pages=book.pages %}of {{ pages }} pages{% endblocktrans %}</p>
{% endif %} {% endif %}
@ -73,9 +86,12 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
<input type="checkbox" class="is-hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
{# bottom bar #} {# bottom bar #}
<div class="columns pt-1"> <input type="checkbox" class="is-hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
<div class="columns mt-1">
<div class="field has-addons column"> <div class="field has-addons column">
<div class="control"> <div class="control">
{% trans "Include spoiler alert" as button_text %} {% trans "Include spoiler alert" as button_text %}

View file

@ -1,10 +1,10 @@
{% load i18n %} {% load i18n %}
{% if rating %} {% if rating %}
{% blocktrans with book_title=book.title display_rating=rating|floatformat:"0" review_title=name count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %} {% blocktrans with book_title=book.title|safe display_rating=rating|floatformat:"0" review_title=name|safe count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %}
{% else %} {% else %}
{% blocktrans with book_title=book.title review_title=name %}Review of "{{ book_title }}": {{ review_title }}{% endblocktrans %} {% blocktrans with book_title=book.title|safe review_title=name|safe %}Review of "{{ book_title }}": {{ review_title }}{% endblocktrans %}
{% endif %} {% endif %}

View file

@ -7,23 +7,23 @@
{% block dropdown-list %} {% block dropdown-list %}
{% for shelf in request.user.shelf_set.all %} {% for shelf in request.user.shelf_set.all %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post"> <form name="shelve" action="/shelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="change-shelf-from" value="{{ current.identifier }}"> <input type="hidden" name="change-shelf-from" value="{{ current.identifier }}">
<input type="hidden" name="shelf" value="{{ shelf.identifier }}"> <input type="hidden" name="shelf" value="{{ shelf.identifier }}">
<button class="button is-fullwidth is-small shelf-option" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button> <button class="button is-fullwidth is-small shelf-option is-radiusless is-white" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button>
</form> </form>
</li> </li>
{% endfor %} {% endfor %}
<li class="navbar-divider" role="presentation"></li> <li class="navbar-divider" role="separator"></li>
<li> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/unshelve/" method="post"> <form name="shelve" action="/unshelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="shelf" value="{{ current.id }}"> <input type="hidden" name="shelf" value="{{ current.id }}">
<button class="button is-fullwidth is-small is-danger is-light" type="submit">{% trans "Remove" %}</button> <button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove" %}</button>
</form> </form>
</li> </li>
{% endblock %} {% endblock %}

View file

@ -7,5 +7,5 @@
{% endblock %} {% endblock %}
{% block dropdown-list %} {% block dropdown-list %}
{% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=request.user.shelf_set.all dropdown=True class="shelf-option is-fullwidth is-small" %} {% include 'snippets/shelve_button/shelve_button_options.html' with active_shelf=active_shelf shelves=request.user.shelf_set.all dropdown=True class="shelf-option is-fullwidth is-small is-radiusless is-white" %}
{% endblock %} {% endblock %}

View file

@ -2,8 +2,8 @@
{% load i18n %} {% load i18n %}
{% for shelf in shelves %} {% for shelf in shelves %}
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %} {% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
{% if dropdown %}<li role="menuitem">{% endif %} {% if dropdown %}<li role="menuitem" class="dropdown-item p-0">{% endif %}
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}"> <div class="{% if not dropdown and active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}">
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %} {% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% trans "Start reading" as button_text %} {% trans "Start reading" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %} {% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}
@ -30,24 +30,20 @@
{% if dropdown %} {% if dropdown %}
{% if readthrough and active_shelf.shelf.identifier != 'read' %} {% if readthrough and active_shelf.shelf.identifier != 'read' %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<div class="dropdown-item pt-0 pb-0">
{% trans "Update progress" as button_text %} {% trans "Update progress" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="progress-update" controls_uid=button_uuid focus="modal-title-progress-update" %} {% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="progress-update" controls_uid=button_uuid focus="modal-title-progress-update" %}
</div>
</li> </li>
{% endif %} {% endif %}
{% if active_shelf.shelf %} {% if active_shelf.shelf %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<div class="dropdown-item pt-0 pb-0">
<form name="shelve" action="/unshelve/" method="post"> <form name="shelve" action="/unshelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}"> <input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}"> <input type="hidden" name="shelf" value="{{ active_shelf.shelf.id }}">
<button class="button is-fullwidth is-small is-danger is-light" type="submit">{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}</button> <button class="button is-fullwidth is-small{% if dropdown %} is-radiusless{% endif %} is-danger is-light" type="submit">{% blocktrans with name=active_shelf.shelf.name %}Remove from {{ name }}{% endblocktrans %}</button>
</form> </form>
</div>
</li> </li>
{% endif %} {% endif %}

View file

@ -13,7 +13,7 @@
</div> </div>
<div class="column"> <div class="column">
<h3 class="title is-6 mb-1">{% include 'snippets/book_titleby.html' with book=book %}</h3> <h3 class="title is-6 mb-1">{% include 'snippets/book_titleby.html' with book=book %}</h3>
<p>{{ book|book_description|default:""|truncatewords_html:20 }}</p> <p>{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}</p>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %} {% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div> </div>
</div> </div>

View file

@ -16,7 +16,7 @@
<div class="card-footer-item"> <div class="card-footer-item">
{# moderation options #} {# moderation options #}
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post"> <form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="button is-danger is-light" type="submit"> <button class="button is-danger is-light" type="submit">
{% trans "Delete status" %} {% trans "Delete status" %}

View file

@ -41,15 +41,16 @@
{% elif status.reply_parent %} {% elif status.reply_parent %}
{% with parent_status=status|parent %} {% with parent_status=status|parent %}
{% if parent_status.status_type == 'Review' %} {% if status.book %}
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">review</a>{% endblocktrans %} {% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
{% elif parent_status.status_type == 'Comment' %} <a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">comment</a>{% endblocktrans %} <span
{% elif parent_status.status_type == 'Quotation' %} itemprop="reviewRating"
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">quote</a>{% endblocktrans %} itemscope
{% else %} itemtype="https://schema.org/Rating"
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">status</a>{% endblocktrans %} >
{% endif %} <span class="is-hidden" {{ rating_type }}>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{% endwith %} {% endwith %}
{% endif %} {% endif %}
@ -79,6 +80,15 @@
{{ status.mention_books.first.title }} {{ status.mention_books.first.title }}
</a> </a>
{% endif %} {% endif %}
{% include 'snippets/stars.html' with rating=status.rating %}
{% endif %}
{% else %}
{% include 'snippets/book_titleby.html' with book=status.book %}
{% endif %}
{% elif status.mention_books %}
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first|title }}</a>
{% endif %}
</h3> </h3>
<p class="is-size-7 is-flex is-align-items-center"> <p class="is-size-7 is-flex is-align-items-center">
<a href="{{ status.remote_id }}">{{ status.published_date|timesince }}</a> <a href="{{ status.remote_id }}">{{ status.published_date|timesince }}</a>
@ -95,4 +105,3 @@
</p> </p>
</div> </div>
</div> </div>

View file

@ -10,19 +10,19 @@
{% block dropdown-list %} {% block dropdown-list %}
{% if status.user == request.user %} {% if status.user == request.user %}
{# things you can do to your own statuses #} {# things you can do to your own statuses #}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post"> <form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="button is-danger is-light is-fullwidth is-small" type="submit"> <button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete status" %} {% trans "Delete status" %}
</button> </button>
</form> </form>
</li> </li>
{% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %} {% if status.status_type != 'GeneratedNote' and status.status_type != 'Rating' %}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post"> <form class="" name="delete-{{status.id}}" action="{% url 'redraft' status.id %}" method="post">
{% csrf_token %} {% csrf_token %}
<button class="button is-danger is-light is-fullwidth is-small" type="submit"> <button class="button is-radiusless is-danger is-light is-fullwidth is-small" type="submit">
{% trans "Delete & re-draft" %} {% trans "Delete & re-draft" %}
</button> </button>
</form> </form>
@ -30,13 +30,15 @@
{% endif %} {% endif %}
{% else %} {% else %}
{# things you can do to other people's statuses #} {# things you can do to other people's statuses #}
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
<a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-fullwidth">{% trans "Send direct message" %}</a> <a href="{% url 'direct-messages-user' status.user|username %}" class="button is-small is-white is-radiusless is-fullwidth">
{% trans "Send direct message" %}
</a>
</li> </li>
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
{% include 'snippets/report_button.html' with user=status.user status=status %} {% include 'snippets/report_button.html' with user=status.user status=status %}
</li> </li>
<li role="menuitem"> <li role="menuitem" class="dropdown-item p-0">
{% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %} {% include 'snippets/block_button.html' with user=status.user class="is-fullwidth" %}
</li> </li>
{% endif %} {% endif %}

View file

@ -1,23 +0,0 @@
{% load i18n %}
<div class="control">
<form name="tag" action="/{% if tag.tag.identifier in user_tags %}untag{% else %}tag{% endif %}/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="name" value="{{ tag.tag.name }}">
<div class="tags has-addons">
<a class="tag" href="{{ tag.tag.local_path }}">
{{ tag.tag.name }}
</a>
{% if tag.tag.identifier in user_tags %}
<button class="tag is-delete" type="submit">
<span class="is-sr-only">{% trans "Remove tag" %}</span>
</button>
{% else %}
<button class="tag" type="submit">+
<span class="is-sr-only">{% trans "Add tag" %}</span>
</button>
{% endif %}
</div>
</form>
</div>

View file

@ -1,14 +0,0 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% block title %}{{ tag.name }}{% endblock %}
{% block content %}
<div class="block">
<h1 class="title">{% blocktrans %}Books tagged "{{ tag.name }}"{% endblocktrans %}</h1>
{% include 'snippets/book_tiles.html' with books=books.all %}
</div>
{% endblock %}

View file

@ -1,7 +1,7 @@
""" template filters """ """ template filters """
from uuid import uuid4 from uuid import uuid4
from django import template from django import template, utils
from django.db.models import Avg from django.db.models import Avg
from bookwyrm import models, views from bookwyrm import models, views
@ -168,6 +168,17 @@ def get_next_shelf(current_shelf):
return "to-read" return "to-read"
@register.filter(name="title")
def get_title(book):
""" display the subtitle if the title is short """
if not book:
return ""
title = book.title
if len(title) < 6 and book.subtitle:
title = "{:s}: {:s}".format(title, book.subtitle)
return title
@register.simple_tag(takes_context=False) @register.simple_tag(takes_context=False)
def related_status(notification): def related_status(notification):
""" for notifications """ """ for notifications """
@ -217,3 +228,10 @@ def active_read_through(book, user):
def comparison_bool(str1, str2): def comparison_bool(str1, str2):
""" idk why I need to write a tag for this, it reutrns a bool """ """ idk why I need to write a tag for this, it reutrns a bool """
return str1 == str2 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("-")]

View file

@ -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"
}
}

View file

@ -1,5 +1,6 @@
{ {
"id": "https://bookwyrm.social/book/5989", "id": "https://bookwyrm.social/book/5989",
"lastEditedBy": "https://example.com/users/rat",
"type": "Edition", "type": "Edition",
"authors": [ "authors": [
"https://bookwyrm.social/author/417" "https://bookwyrm.social/author/417"

View file

@ -155,8 +155,8 @@ class ActivitypubMixins(TestCase):
recipients = ActivitypubMixin.get_recipients(mock_self) recipients = ActivitypubMixin.get_recipients(mock_self)
self.assertEqual(len(recipients), 2) self.assertEqual(len(recipients), 2)
self.assertEqual(recipients[0], another_remote_user.inbox) self.assertTrue(another_remote_user.inbox in recipients)
self.assertEqual(recipients[1], self.remote_user.inbox) self.assertTrue(self.remote_user.inbox in recipients)
def test_get_recipients_direct(self, _): def test_get_recipients_direct(self, _):
""" determines the recipients for a user's object broadcast """ """ determines the recipients for a user's object broadcast """

View file

@ -269,7 +269,7 @@ class Status(TestCase):
def test_review_to_pure_activity(self, *_): def test_review_to_pure_activity(self, *_):
""" subclass of the base model version with a "pure" serializer """ """ subclass of the base model version with a "pure" serializer """
status = models.Review.objects.create( status = models.Review.objects.create(
name="Review name", name="Review's name",
content="test content", content="test content",
rating=3.0, rating=3.0,
user=self.local_user, user=self.local_user,
@ -280,7 +280,7 @@ class Status(TestCase):
self.assertEqual(activity["type"], "Article") self.assertEqual(activity["type"], "Article")
self.assertEqual( self.assertEqual(
activity["name"], activity["name"],
'Review of "%s" (3 stars): Review name' % self.book.title, 'Review of "%s" (3 stars): Review\'s name' % self.book.title,
) )
self.assertEqual(activity["content"], "test content") self.assertEqual(activity["content"], "test content")
self.assertEqual(activity["attachment"][0].type, "Document") self.assertEqual(activity["attachment"][0].type, "Document")

View file

@ -51,7 +51,7 @@ class InboxActivities(TestCase):
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
@patch("bookwyrm.activitystreams.ActivityStream.add_status") @patch("bookwyrm.activitystreams.ActivityStream.add_status")
def test_handle_boost(self, _): def test_boost(self, redis_mock):
""" boost a status """ """ boost a status """
self.assertEqual(models.Notification.objects.count(), 0) self.assertEqual(models.Notification.objects.count(), 0)
activity = { activity = {
@ -66,16 +66,23 @@ class InboxActivities(TestCase):
with patch("bookwyrm.models.status.Status.ignore_activity") as discarder: with patch("bookwyrm.models.status.Status.ignore_activity") as discarder:
discarder.return_value = False discarder.return_value = False
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
# boost added to redis activitystreams
self.assertTrue(redis_mock.called)
# boost created of correct status
boost = models.Boost.objects.get() boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, self.status) self.assertEqual(boost.boosted_status, self.status)
# notification sent to original poster
notification = models.Notification.objects.get() notification = models.Notification.objects.get()
self.assertEqual(notification.user, self.local_user) self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_status, self.status) self.assertEqual(notification.related_status, self.status)
@responses.activate @responses.activate
@patch("bookwyrm.activitystreams.ActivityStream.add_status") @patch("bookwyrm.activitystreams.ActivityStream.add_status")
def test_handle_boost_remote_status(self, redis_mock): def test_boost_remote_status(self, redis_mock):
""" boost a status """ """ boost a status from a remote server """
work = models.Work.objects.create(title="work title") work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create( book = models.Edition.objects.create(
title="Test", title="Test",
@ -123,7 +130,7 @@ class InboxActivities(TestCase):
self.assertEqual(boost.boosted_status.comment.book, book) self.assertEqual(boost.boosted_status.comment.book, book)
@responses.activate @responses.activate
def test_handle_discarded_boost(self): def test_discarded_boost(self):
""" test a boost of a mastodon status that will be discarded """ """ test a boost of a mastodon status that will be discarded """
status = models.Status( status = models.Status(
content="hi", content="hi",
@ -146,7 +153,7 @@ class InboxActivities(TestCase):
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertEqual(models.Boost.objects.count(), 0) self.assertEqual(models.Boost.objects.count(), 0)
def test_handle_unboost(self): def test_unboost(self):
""" undo a boost """ """ undo a boost """
with patch("bookwyrm.activitystreams.ActivityStream.add_status"): with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
boost = models.Boost.objects.create( boost = models.Boost.objects.create(
@ -175,7 +182,7 @@ class InboxActivities(TestCase):
self.assertTrue(redis_mock.called) self.assertTrue(redis_mock.called)
self.assertFalse(models.Boost.objects.exists()) self.assertFalse(models.Boost.objects.exists())
def test_handle_unboost_unknown_boost(self): def test_unboost_unknown_boost(self):
""" undo a boost """ """ undo a boost """
activity = { activity = {
"type": "Undo", "type": "Undo",

View file

@ -1,4 +1,5 @@
""" tests incoming activities""" """ tests incoming activities"""
import json
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
@ -34,7 +35,7 @@ class InboxRelationships(TestCase):
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
def test_handle_follow(self): def test_follow(self):
""" remote user wants to follow local user """ """ remote user wants to follow local user """
activity = { activity = {
"@context": "https://www.w3.org/ns/activitystreams", "@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: with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertEqual(mock.call_count, 1) self.assertEqual(mock.call_count, 1)
response_activity = json.loads(mock.call_args[0][1])
self.assertEqual(response_activity["type"], "Accept")
# notification created # notification created
notification = models.Notification.objects.get() notification = models.Notification.objects.get()
@ -61,7 +64,34 @@ class InboxRelationships(TestCase):
follow = models.UserFollows.objects.get(user_object=self.local_user) follow = models.UserFollows.objects.get(user_object=self.local_user)
self.assertEqual(follow.user_subject, self.remote_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 """ """ needs approval before following """
activity = { activity = {
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
@ -91,7 +121,7 @@ class InboxRelationships(TestCase):
follow = models.UserFollows.objects.all() follow = models.UserFollows.objects.all()
self.assertEqual(list(follow), []) self.assertEqual(list(follow), [])
def test_handle_undo_follow_request(self): def test_undo_follow_request(self):
""" the requester cancels a follow request """ """ the requester cancels a follow request """
self.local_user.manually_approves_followers = True self.local_user.manually_approves_followers = True
self.local_user.save(broadcast=False) self.local_user.save(broadcast=False)
@ -121,7 +151,7 @@ class InboxRelationships(TestCase):
self.assertFalse(self.local_user.follower_requests.exists()) self.assertFalse(self.local_user.follower_requests.exists())
def test_handle_unfollow(self): def test_unfollow(self):
""" remove a relationship """ """ remove a relationship """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
rel = models.UserFollows.objects.create( rel = models.UserFollows.objects.create(
@ -146,7 +176,7 @@ class InboxRelationships(TestCase):
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertIsNone(self.local_user.followers.first()) 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 """ """ a remote user approved a follow request from local """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
rel = models.UserFollowRequest.objects.create( rel = models.UserFollowRequest.objects.create(
@ -177,7 +207,7 @@ class InboxRelationships(TestCase):
self.assertEqual(follows.count(), 1) self.assertEqual(follows.count(), 1)
self.assertEqual(follows.first(), self.local_user) self.assertEqual(follows.first(), self.local_user)
def test_handle_follow_reject(self): def test_follow_reject(self):
""" turn down a follow request """ """ turn down a follow request """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
rel = models.UserFollowRequest.objects.create( rel = models.UserFollowRequest.objects.create(

View file

@ -23,6 +23,16 @@ class InboxUpdate(TestCase):
) )
self.local_user.remote_id = "https://example.com/user/mouse" self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False) 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 = { self.create_json = {
"id": "hi", "id": "hi",
@ -34,7 +44,7 @@ class InboxUpdate(TestCase):
} }
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
def test_handle_update_list(self): def test_update_list(self):
""" a new list """ """ a new list """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
book_list = models.List.objects.create( book_list = models.List.objects.create(
@ -68,16 +78,24 @@ class InboxUpdate(TestCase):
self.assertEqual(book_list.description, "summary text") self.assertEqual(book_list.description, "summary text")
self.assertEqual(book_list.remote_id, "https://example.com/list/22") 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 """ """ update an existing user """
# we only do this with remote users models.UserFollows.objects.create(
self.local_user.local = False user_subject=self.local_user,
self.local_user.save() 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()) userdata = json.loads(datafile.read_bytes())
del userdata["icon"] 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( views.inbox.activity_task(
{ {
"type": "Update", "type": "Update",
@ -88,13 +106,16 @@ class InboxUpdate(TestCase):
"object": userdata, "object": userdata,
} }
) )
user = models.User.objects.get(id=self.local_user.id) user = models.User.objects.get(id=self.remote_user.id)
self.assertEqual(user.name, "MOUSE?? MOUSE!!") self.assertEqual(user.name, "RAT???")
self.assertEqual(user.username, "mouse@example.com") self.assertEqual(user.username, "rat@example.com")
self.assertEqual(user.localname, "mouse")
self.assertTrue(user.discoverable) 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 """ """ update an existing edition """
datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_edition.json") datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_edition.json")
bookdata = json.loads(datafile.read_bytes()) bookdata = json.loads(datafile.read_bytes())
@ -122,8 +143,9 @@ class InboxUpdate(TestCase):
) )
book = models.Edition.objects.get(id=book.id) book = models.Edition.objects.get(id=book.id)
self.assertEqual(book.title, "Piranesi") 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 """ """ update an existing edition """
datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_work.json") datafile = pathlib.Path(__file__).parent.joinpath("../../data/bw_work.json")
bookdata = json.loads(datafile.read_bytes()) bookdata = json.loads(datafile.read_bytes())

View file

@ -1,4 +1,5 @@
""" test for app action functionality """ """ test for app action functionality """
import json
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -39,7 +40,7 @@ class InteractionViews(TestCase):
parent_work=work, parent_work=work,
) )
def test_handle_favorite(self, _): def test_favorite(self, _):
""" create and broadcast faving a status """ """ create and broadcast faving a status """
view = views.Favorite.as_view() view = views.Favorite.as_view()
request = self.factory.post("") request = self.factory.post("")
@ -57,7 +58,7 @@ class InteractionViews(TestCase):
self.assertEqual(notification.user, self.local_user) self.assertEqual(notification.user, self.local_user)
self.assertEqual(notification.related_user, self.remote_user) self.assertEqual(notification.related_user, self.remote_user)
def test_handle_unfavorite(self, _): def test_unfavorite(self, _):
""" unfav a status """ """ unfav a status """
view = views.Unfavorite.as_view() view = views.Unfavorite.as_view()
request = self.factory.post("") request = self.factory.post("")
@ -74,7 +75,7 @@ class InteractionViews(TestCase):
self.assertEqual(models.Favorite.objects.count(), 0) self.assertEqual(models.Favorite.objects.count(), 0)
self.assertEqual(models.Notification.objects.count(), 0) self.assertEqual(models.Notification.objects.count(), 0)
def test_handle_boost(self, _): def test_boost(self, _):
""" boost a status """ """ boost a status """
view = views.Boost.as_view() view = views.Boost.as_view()
request = self.factory.post("") request = self.factory.post("")
@ -85,6 +86,7 @@ class InteractionViews(TestCase):
view(request, status.id) view(request, status.id)
boost = models.Boost.objects.get() boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, status) self.assertEqual(boost.boosted_status, status)
self.assertEqual(boost.user, self.remote_user) self.assertEqual(boost.user, self.remote_user)
self.assertEqual(boost.privacy, "public") self.assertEqual(boost.privacy, "public")
@ -95,7 +97,7 @@ class InteractionViews(TestCase):
self.assertEqual(notification.related_user, self.remote_user) self.assertEqual(notification.related_user, self.remote_user)
self.assertEqual(notification.related_status, status) self.assertEqual(notification.related_status, status)
def test_handle_self_boost(self, _): def test_self_boost(self, _):
""" boost your own status """ """ boost your own status """
view = views.Boost.as_view() view = views.Boost.as_view()
request = self.factory.post("") request = self.factory.post("")
@ -103,8 +105,15 @@ class InteractionViews(TestCase):
with patch("bookwyrm.activitystreams.ActivityStream.add_status"): with patch("bookwyrm.activitystreams.ActivityStream.add_status"):
status = models.Status.objects.create(user=self.local_user, content="hi") status = models.Status.objects.create(user=self.local_user, content="hi")
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as broadcast_mock:
view(request, status.id) view(request, status.id)
self.assertEqual(broadcast_mock.call_count, 1)
activity = json.loads(broadcast_mock.call_args[0][1])
self.assertEqual(activity["type"], "Announce")
boost = models.Boost.objects.get() boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, status) self.assertEqual(boost.boosted_status, status)
self.assertEqual(boost.user, self.local_user) self.assertEqual(boost.user, self.local_user)
@ -112,7 +121,7 @@ class InteractionViews(TestCase):
self.assertFalse(models.Notification.objects.exists()) self.assertFalse(models.Notification.objects.exists())
def test_handle_boost_unlisted(self, _): def test_boost_unlisted(self, _):
""" boost a status """ """ boost a status """
view = views.Boost.as_view() view = views.Boost.as_view()
request = self.factory.post("") request = self.factory.post("")
@ -127,7 +136,7 @@ class InteractionViews(TestCase):
boost = models.Boost.objects.get() boost = models.Boost.objects.get()
self.assertEqual(boost.privacy, "unlisted") self.assertEqual(boost.privacy, "unlisted")
def test_handle_boost_private(self, _): def test_boost_private(self, _):
""" boost a status """ """ boost a status """
view = views.Boost.as_view() view = views.Boost.as_view()
request = self.factory.post("") request = self.factory.post("")
@ -140,7 +149,7 @@ class InteractionViews(TestCase):
view(request, status.id) view(request, status.id)
self.assertFalse(models.Boost.objects.exists()) self.assertFalse(models.Boost.objects.exists())
def test_handle_boost_twice(self, _): def test_boost_twice(self, _):
""" boost a status """ """ boost a status """
view = views.Boost.as_view() view = views.Boost.as_view()
request = self.factory.post("") request = self.factory.post("")
@ -152,7 +161,7 @@ class InteractionViews(TestCase):
view(request, status.id) view(request, status.id)
self.assertEqual(models.Boost.objects.count(), 1) self.assertEqual(models.Boost.objects.count(), 1)
def test_handle_unboost(self, _): def test_unboost(self, _):
""" undo a boost """ """ undo a boost """
view = views.Unboost.as_view() view = views.Unboost.as_view()
request = self.factory.post("") request = self.factory.post("")

View file

@ -1,119 +0,0 @@
""" test for app action functionality """
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
class TagViews(TestCase):
""" tag views"""
def setUp(self):
""" we need basic test data and mocks """
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
"mouse@local.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
remote_id="https://example.com/users/mouse",
)
self.group = Group.objects.create(name="editor")
self.group.permissions.add(
Permission.objects.create(
name="edit_book",
codename="edit_book",
content_type=ContentType.objects.get_for_model(models.User),
).id
)
self.work = models.Work.objects.create(title="Test Work")
self.book = models.Edition.objects.create(
title="Example Edition",
remote_id="https://example.com/book/1",
parent_work=self.work,
)
models.SiteSettings.objects.create()
def test_tag_page(self):
""" there are so many views, this just makes sure it LOADS """
view = views.Tag.as_view()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
tag = models.Tag.objects.create(name="hi there")
models.UserTag.objects.create(tag=tag, user=self.local_user, book=self.book)
request = self.factory.get("")
with patch("bookwyrm.views.tag.is_api_request") as is_api:
is_api.return_value = False
result = view(request, tag.identifier)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
request = self.factory.get("")
with patch("bookwyrm.views.tag.is_api_request") as is_api:
is_api.return_value = True
result = view(request, tag.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_tag_page_activitypub_page(self):
""" there are so many views, this just makes sure it LOADS """
view = views.Tag.as_view()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
tag = models.Tag.objects.create(name="hi there")
models.UserTag.objects.create(tag=tag, user=self.local_user, book=self.book)
request = self.factory.get("", {"page": 1})
with patch("bookwyrm.views.tag.is_api_request") as is_api:
is_api.return_value = True
result = view(request, tag.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_tag(self):
""" add a tag to a book """
view = views.AddTag.as_view()
request = self.factory.post(
"",
{
"name": "A Tag!?",
"book": self.book.id,
},
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request)
tag = models.Tag.objects.get()
user_tag = models.UserTag.objects.get()
self.assertEqual(tag.name, "A Tag!?")
self.assertEqual(tag.identifier, "A+Tag%21%3F")
self.assertEqual(user_tag.user, self.local_user)
self.assertEqual(user_tag.book, self.book)
def test_untag(self):
""" remove a tag from a book """
view = views.RemoveTag.as_view()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
tag = models.Tag.objects.create(name="A Tag!?")
models.UserTag.objects.create(user=self.local_user, book=self.book, tag=tag)
request = self.factory.post(
"",
{
"user": self.local_user.id,
"book": self.book.id,
"name": tag.name,
},
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request)
self.assertTrue(models.Tag.objects.filter(name="A Tag!?").exists())
self.assertFalse(models.UserTag.objects.exists())

View file

@ -277,11 +277,6 @@ urlpatterns = [
# author # author
re_path(r"^author/(?P<author_id>\d+)(.json)?/?$", views.Author.as_view()), re_path(r"^author/(?P<author_id>\d+)(.json)?/?$", views.Author.as_view()),
re_path(r"^author/(?P<author_id>\d+)/edit/?$", views.EditAuthor.as_view()), re_path(r"^author/(?P<author_id>\d+)/edit/?$", views.EditAuthor.as_view()),
# tags
re_path(r"^tag/(?P<tag_id>.+)\.json/?$", views.Tag.as_view()),
re_path(r"^tag/(?P<tag_id>.+)/?$", views.Tag.as_view()),
re_path(r"^tag/?$", views.AddTag.as_view()),
re_path(r"^untag/?$", views.RemoveTag.as_view()),
# reading progress # reading progress
re_path(r"^edit-readthrough/?$", views.edit_readthrough, name="edit-readthrough"), re_path(r"^edit-readthrough/?$", views.edit_readthrough, name="edit-readthrough"),
re_path(r"^delete-readthrough/?$", views.delete_readthrough), re_path(r"^delete-readthrough/?$", views.delete_readthrough),

View file

@ -34,7 +34,6 @@ from .shelf import create_shelf, delete_shelf
from .shelf import shelve, unshelve from .shelf import shelve, unshelve
from .site import Site from .site import Site
from .status import CreateStatus, DeleteStatus, DeleteAndRedraft from .status import CreateStatus, DeleteStatus, DeleteAndRedraft
from .tag import Tag, AddTag, RemoveTag
from .updates import get_notification_count, get_unread_status_count from .updates import get_notification_count, get_unread_status_count
from .user import User, EditUser, Followers, Following from .user import User, EditUser, Followers, Following
from .user_admin import UserAdmin, UserAdminList from .user_admin import UserAdmin, UserAdminList

View file

@ -57,12 +57,7 @@ class Book(View):
) )
reviews_page = paginated.get_page(request.GET.get("page")) reviews_page = paginated.get_page(request.GET.get("page"))
user_tags = readthroughs = user_shelves = other_edition_shelves = []
if request.user.is_authenticated: if request.user.is_authenticated:
user_tags = models.UserTag.objects.filter(
book=book, user=request.user
).values_list("tag__identifier", flat=True)
readthroughs = models.ReadThrough.objects.filter( readthroughs = models.ReadThrough.objects.filter(
user=request.user, user=request.user,
book=book, book=book,
@ -87,11 +82,9 @@ class Book(View):
"review_count": reviews.count(), "review_count": reviews.count(),
"ratings": reviews.filter(Q(content__isnull=True) | Q(content="")), "ratings": reviews.filter(Q(content__isnull=True) | Q(content="")),
"rating": reviews.aggregate(Avg("rating"))["rating__avg"], "rating": reviews.aggregate(Avg("rating"))["rating__avg"],
"tags": models.UserTag.objects.filter(book=book),
"lists": privacy_filter( "lists": privacy_filter(
request.user, book.list_set.filter(listitem__approved=True) request.user, book.list_set.filter(listitem__approved=True)
), ),
"user_tags": user_tags,
"user_shelves": user_shelves, "user_shelves": user_shelves,
"other_edition_shelves": other_edition_shelves, "other_edition_shelves": other_edition_shelves,
"readthroughs": readthroughs, "readthroughs": readthroughs,

View file

@ -1,73 +0,0 @@
""" tagging views"""
from django.contrib.auth.decorators import login_required
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 bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse
from .helpers import is_api_request
# pylint: disable= no-self-use
class Tag(View):
""" tag page """
def get(self, request, tag_id):
""" see books related to a tag """
tag_obj = get_object_or_404(models.Tag, identifier=tag_id)
if is_api_request(request):
return ActivitypubResponse(tag_obj.to_activity(**request.GET))
books = models.Edition.objects.filter(
usertag__tag__identifier=tag_id
).distinct()
data = {
"books": books,
"tag": tag_obj,
}
return TemplateResponse(request, "tag.html", data)
@method_decorator(login_required, name="dispatch")
class AddTag(View):
""" add a tag to a book """
def post(self, request):
""" tag a book """
# I'm not using a form here because sometimes "name" is sent as a hidden
# field which doesn't validate
name = request.POST.get("name")
book_id = request.POST.get("book")
book = get_object_or_404(models.Edition, id=book_id)
tag_obj, _ = models.Tag.objects.get_or_create(
name=name,
)
models.UserTag.objects.get_or_create(
user=request.user,
book=book,
tag=tag_obj,
)
return redirect("/book/%s" % book_id)
@method_decorator(login_required, name="dispatch")
class RemoveTag(View):
""" remove a user's tag from a book """
def post(self, request):
""" untag a book """
name = request.POST.get("name")
tag_obj = get_object_or_404(models.Tag, name=name)
book_id = request.POST.get("book")
book = get_object_or_404(models.Edition, id=book_id)
user_tag = get_object_or_404(
models.UserTag, tag=tag_obj, book=book, user=request.user
)
user_tag.delete()
return redirect("/book/%s" % book_id)

4
bw-dev
View file

@ -90,10 +90,10 @@ case "$CMD" in
runweb python manage.py collectstatic --no-input runweb python manage.py collectstatic --no-input
;; ;;
makemessages) makemessages)
runweb django-admin makemessages --no-wrap --ignore=venv3 $@ runweb django-admin makemessages --no-wrap --ignore=venv $@
;; ;;
compilemessages) compilemessages)
runweb django-admin compilemessages --ignore venv3 $@ runweb django-admin compilemessages --ignore venv $@
;; ;;
build) build)
docker-compose build docker-compose build

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.