Merge branch 'main' into django-3-2

This commit is contained in:
Mouse Reeve 2021-04-26 11:21:36 -07:00
commit 0889c57b86
214 changed files with 4734 additions and 3265 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

@ -3,7 +3,7 @@ import datetime
from collections import defaultdict from collections import defaultdict
from django import forms from django import forms
from django.forms import ModelForm, PasswordInput, widgets from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
from django.forms.widgets import Textarea from django.forms.widgets import Textarea
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -150,12 +150,10 @@ class LimitedEditUserForm(CustomForm):
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}
class TagForm(CustomForm): class UserGroupForm(CustomForm):
class Meta: class Meta:
model = models.Tag model = models.User
fields = ["name"] fields = ["groups"]
help_texts = {f: None for f in fields}
labels = {"name": "Add a tag"}
class CoverForm(CustomForm): class CoverForm(CustomForm):
@ -287,3 +285,20 @@ class ServerForm(CustomForm):
class Meta: class Meta:
model = models.FederatedServer model = models.FederatedServer
exclude = ["remote_id"] exclude = ["remote_id"]
class SortListForm(forms.Form):
sort_by = ChoiceField(
choices=(
("order", _("List Order")),
("title", _("Book Title")),
("rating", _("Rating")),
),
label=_("Sort By"),
)
direction = ChoiceField(
choices=(
("ascending", _("Ascending")),
("descending", _("Descending")),
),
)

View file

@ -0,0 +1,30 @@
from django.db import migrations
def forwards_func(apps, schema_editor):
# Set all values for ListItem.order
BookList = apps.get_model("bookwyrm", "List")
db_alias = schema_editor.connection.alias
for book_list in BookList.objects.using(db_alias).all():
for i, item in enumerate(book_list.listitem_set.order_by("id"), 1):
item.order = i
item.save()
def reverse_func(apps, schema_editor):
# null all values for ListItem.order
BookList = apps.get_model("bookwyrm", "List")
db_alias = schema_editor.connection.alias
for book_list in BookList.objects.using(db_alias).all():
for item in book_list.listitem_set.order_by("id"):
item.order = None
item.save()
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0066_user_deactivation_reason"),
]
operations = [migrations.RunPython(forwards_func, reverse_func)]

View file

@ -0,0 +1,23 @@
# Generated by Django 3.1.6 on 2021-04-08 16:15
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0067_denullify_list_item_order"),
]
operations = [
migrations.AlterField(
model_name="listitem",
name="order",
field=bookwyrm.models.fields.IntegerField(),
),
migrations.AlterUniqueTogether(
name="listitem",
unique_together={("order", "book_list"), ("book", "book_list")},
),
]

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"""
@ -163,7 +167,7 @@ class Work(OrderedCollectionPageMixin, Book):
return self.to_ordered_collection( return self.to_ordered_collection(
self.editions.order_by("-edition_rank").all(), self.editions.order_by("-edition_rank").all(),
remote_id="%s/editions" % self.remote_id, remote_id="%s/editions" % self.remote_id,
**kwargs **kwargs,
) )
activity_serializer = activitypub.Work activity_serializer = activitypub.Work

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

@ -47,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
@property @property
def collection_queryset(self): def collection_queryset(self):
"""list of books for this shelf, overrides OrderedCollectionMixin""" """list of books for this shelf, overrides OrderedCollectionMixin"""
return self.books.filter(listitem__approved=True).all().order_by("listitem") return self.books.filter(listitem__approved=True).order_by("listitem")
class Meta: class Meta:
"""default sorting""" """default sorting"""
@ -67,7 +67,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
) )
notes = fields.TextField(blank=True, null=True) notes = fields.TextField(blank=True, null=True)
approved = models.BooleanField(default=True) approved = models.BooleanField(default=True)
order = fields.IntegerField(blank=True, null=True) order = fields.IntegerField()
endorsement = models.ManyToManyField("User", related_name="endorsers") endorsement = models.ManyToManyField("User", related_name="endorsers")
activity_serializer = activitypub.ListItem activity_serializer = activitypub.ListItem
@ -93,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
) )
class Meta: class Meta:
""" an opinionated constraint! you can't put a book on a list twice """ # A book may only be placed into a list once, and each order in the list may be used only
# once
unique_together = ("book", "book_list") unique_together = (("book", "book_list"), ("order", "book_list"))
ordering = ("-created_date",) ordering = ("-created_date",)

View file

@ -50,11 +50,10 @@ class UserRelationship(BookWyrmModel):
), ),
] ]
def get_remote_id(self, status=None): # pylint: disable=arguments-differ def get_remote_id(self):
"""use shelf identifier in remote_id""" """use shelf identifier in remote_id"""
status = status or "follows"
base_path = self.user_subject.remote_id base_path = self.user_subject.remote_id
return "%s#%s/%d" % (base_path, status, self.id) return "%s#follows/%d" % (base_path, self.id)
class UserFollows(ActivityMixin, UserRelationship): class UserFollows(ActivityMixin, UserRelationship):
@ -102,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(
@ -138,16 +140,25 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
notification_type=notification_type, notification_type=notification_type,
) )
def accept(self): def get_accept_reject_id(self, status):
"""get id for sending an accept or reject of a local user"""
base_path = self.user_object.remote_id
return "%s#%s/%d" % (base_path, status, self.id or 0)
def accept(self, broadcast_only=False):
"""turn this request into the real deal""" """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:
activity = activitypub.Accept( activity = activitypub.Accept(
id=self.get_remote_id(status="accepts"), id=self.get_accept_reject_id(status="accepts"),
actor=self.user_object.remote_id, actor=self.user_object.remote_id,
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()
@ -156,7 +167,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
"""generate a Reject for this follow request""" """generate a Reject for this follow request"""
if self.user_object.local: if self.user_object.local:
activity = activitypub.Reject( activity = activitypub.Reject(
id=self.get_remote_id(status="rejects"), id=self.get_accept_reject_id(status="rejects"),
actor=self.user_object.remote_id, actor=self.user_object.remote_id,
object=self.to_activity(), object=self.to_activity(),
).serialize() ).serialize()

View file

@ -48,7 +48,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
@property @property
def collection_queryset(self): def collection_queryset(self):
"""list of books for this shelf, overrides OrderedCollectionMixin""" """list of books for this shelf, overrides OrderedCollectionMixin"""
return self.books.all().order_by("shelfbook") return self.books.order_by("shelfbook")
def get_remote_id(self): def get_remote_id(self):
"""shelf identifier instead of id""" """shelf identifier instead of id"""

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

@ -153,7 +153,7 @@ LANGUAGES = [
("de-de", _("German")), ("de-de", _("German")),
("es", _("Spanish")), ("es", _("Spanish")),
("fr-fr", _("French")), ("fr-fr", _("French")),
("zh-cn", _("Simplified Chinese")), ("zh-hans", _("Simplified Chinese")),
] ]

View file

@ -1,6 +1,5 @@
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-padding-top: 20%;
} }
body { body {
@ -30,6 +29,40 @@ body {
min-width: 75% !important; min-width: 75% !important;
} }
/** Utilities not covered by Bulma
******************************************************************************/
@media only screen and (max-width: 768px) {
.is-sr-only-mobile {
border: none !important;
clip: rect(0, 0, 0, 0) !important;
height: 0.01em !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
white-space: nowrap !important;
width: 0.01em !important;
}
.m-0-mobile {
margin: 0 !important;
}
}
.button.is-transparent {
background-color: transparent;
}
.card.is-stretchable {
display: flex;
flex-direction: column;
height: 100%;
}
.card.is-stretchable .card-content {
flex-grow: 1;
}
/** Shelving /** Shelving
******************************************************************************/ ******************************************************************************/
@ -86,6 +119,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.
@ -256,3 +296,53 @@ body {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Book preview table
******************************************************************************/
.book-preview td {
vertical-align: middle;
}
@media only screen and (max-width: 768px) {
table.is-mobile,
table.is-mobile tbody {
display: block;
}
table.is-mobile tr {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
border-top: 1px solid #dbdbdb;
}
table.is-mobile td {
display: block;
box-sizing: border-box;
flex: 1 0 100%;
order: 2;
border-bottom: 0;
}
table.is-mobile td.book-preview-top-row {
order: 1;
flex-basis: auto;
}
table.is-mobile td[data-title]:not(:empty)::before {
content: attr(data-title);
display: block;
font-size: 0.75em;
font-weight: bold;
}
table.is-mobile td:empty {
padding: 0;
}
table.is-mobile th,
table.is-mobile thead {
display: none;
}
}

View file

@ -67,31 +67,16 @@
</div> </div>
{% endif %} {% endif %}
<section class="content is-clipped"> <section class="is-clipped">
<dl> {% with book=book %}
{% if book.isbn_13 %} <div class="content">
<div class="is-flex is-justify-content-space-between is-align-items-center"> {% include 'book/publisher_info.html' %}
<dt>{% trans "ISBN:" %}</dt>
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
</div> </div>
{% endif %}
{% if book.oclc_number %} <div class="my-3">
<div class="is-flex is-justify-content-space-between is-align-items-center"> {% include 'book/book_identifiers.html' %}
<dt>{% trans "OCLC Number:" %}</dt>
<dd>{{ book.oclc_number }}</dd>
</div> </div>
{% endif %} {% endwith %}
{% if book.asin %}
<div class="is-flex is-justify-content-space-between is-align-items-center">
<dt>{% trans "ASIN:" %}</dt>
<dd>{{ book.asin }}</dd>
</div>
{% endif %}
</dl>
{% include 'book/publisher_info.html' with book=book %}
{% if book.openlibrary_key %} {% if book.openlibrary_key %}
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p> <p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
@ -261,7 +246,36 @@
</div> </div>
<div class="block" id="reviews"> <div class="block" id="reviews">
{% for review in reviews %} {% if request.user.is_authenticated %}
<nav class="tabs">
<ul>
{% url 'book' book.id as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Reviews" %} ({{ review_count }})</a>
</li>
{% if user_statuses.review_count %}
{% url 'book-user-statuses' book.id 'review' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Your reviews" %} ({{ user_statuses.review_count }})</a>
</li>
{% endif %}
{% if user_statuses.comment_count %}
{% url 'book-user-statuses' book.id 'comment' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Your comments" %} ({{ user_statuses.comment_count }})</a>
</li>
{% endif %}
{% if user_statuses.quotation_count %}
{% url 'book-user-statuses' book.id 'quote' as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}">{% trans "Your quotes" %} ({{ user_statuses.quotation_count }})</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% for review in statuses %}
<div <div
class="block" class="block"
itemprop="review" itemprop="review"
@ -302,7 +316,7 @@
{% 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=statuses path=request.path anchor="#reviews" %}
</div> </div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,27 @@
{% spaceless %}
{% load i18n %}
<dl>
{% if book.isbn_13 %}
<div class="is-flex">
<dt class="mr-1">{% trans "ISBN:" %}</dt>
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
</div>
{% endif %}
{% if book.oclc_number %}
<div class="is-flex">
<dt class="mr-1">{% trans "OCLC Number:" %}</dt>
<dd>{{ book.oclc_number }}</dd>
</div>
{% endif %}
{% if book.asin %}
<div class="is-flex">
<dt class="mr-1">{% trans "ASIN:" %}</dt>
<dd>{{ book.asin }}</dd>
</div>
{% endif %}
</dl>
{% endspaceless %}

View file

@ -109,7 +109,10 @@
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}
<p class="mb-2"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p> <p class="mb-2">
<label class="label" for="id_series">{% trans "Series:" %}</label>
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
</p>
{% for error in form.series.errors %} {% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p> <p class="help is-danger">{{ error | escape }}</p>
{% endfor %} {% endfor %}

View file

@ -25,7 +25,18 @@
{{ book.title }} {{ book.title }}
</a> </a>
</h2> </h2>
{% include 'book/publisher_info.html' with book=book %}
{% with book=book %}
<div class="columns is-multiline">
<div class="column is-half">
{% include 'book/publisher_info.html' %}
</div>
<div class="column is-half ">
{% include 'book/book_identifiers.html' %}
</div>
</div>
{% endwith %}
</div> </div>
<div class="column is-3"> <div class="column is-3">
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %} {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}

View file

@ -1,6 +1,7 @@
{% spaceless %} {% spaceless %}
{% load i18n %} {% load i18n %}
{% load humanize %}
<p> <p>
{% with format=book.physical_format pages=book.pages %} {% with format=book.physical_format pages=book.pages %}
@ -39,7 +40,7 @@
{% endif %} {% endif %}
<p> <p>
{% with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %} {% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
{% if date or book.first_published_date %} {% if date or book.first_published_date %}
<meta <meta
itemprop="datePublished" itemprop="datePublished"

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

@ -41,7 +41,7 @@
<div class="columns is-multiline"> <div class="columns is-multiline">
{% for user in users %} {% for user in users %}
<div class="column is-one-third"> <div class="column is-one-third">
<div class="card block"> <div class="card is-stretchable">
<div class="card-content"> <div class="card-content">
<div class="media"> <div class="media">
<a href="{{ user.local_path }}" class="media-left"> <a href="{{ user.local_path }}" class="media-left">
@ -56,13 +56,13 @@
</div> </div>
</div> </div>
<div class="content"> <div>
{% if user.summary %} {% if user.summary %}
{{ user.summary | to_markdown | safe | truncatechars_html:40 }} {{ user.summary | to_markdown | safe | truncatechars_html:40 }}
{% else %}&nbsp;{% endif %} {% else %}&nbsp;{% endif %}
</div> </div>
</div> </div>
<footer class="card-footer content"> <footer class="card-footer">
{% if user != request.user %} {% if user != request.user %}
{% if user.mutuals %} {% if user.mutuals %}
<div class="card-footer-item"> <div class="card-footer-item">

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

@ -13,10 +13,10 @@
<div class="columns mt-3"> <div class="columns mt-3">
<section class="column is-three-quarters"> <section class="column is-three-quarters">
{% if not items.exists %} {% if not items.object_list.exists %}
<p>{% trans "This list is currently empty" %}</p> <p>{% trans "This list is currently empty" %}</p>
{% else %} {% else %}
<ol> <ol start="{{ items.start_index }}">
{% for item in items %} {% for item in items %}
<li class="block pb-3"> <li class="block pb-3">
<div class="card"> <div class="card">
@ -30,11 +30,27 @@
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %} {% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
</div> </div>
</div> </div>
<div class="card-footer has-background-white-bis"> <div class="card-footer has-background-white-bis is-align-items-baseline">
<div class="card-footer-item"> <div class="card-footer-item">
<div>
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p> <p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
</div> </div>
</div>
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %} {% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
<div class="card-footer-item">
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
<div class="field has-addons mb-0">
{% csrf_token %}
<div class="control">
<input id="input-list-position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
</div>
<div class="control">
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
</div>
</div>
<label for="input-list-position" class="help">{% trans "List position" %}</label>
</form>
</div>
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item"> <form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="item" value="{{ item.id }}"> <input type="hidden" name="item" value="{{ item.id }}">
@ -47,10 +63,27 @@
{% endfor %} {% endfor %}
</ol> </ol>
{% endif %} {% endif %}
{% include "snippets/pagination.html" with page=items %}
</section> </section>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<section class="column is-one-quarter content"> <section class="column is-one-quarter content">
<h2>{% trans "Sort List" %}</h2>
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
<div class="select is-fullwidth">
{{ sort_form.sort_by }}
</div>
<label class="label" for="id_direction">{% trans "Direction" %}</label>
<div class="select is-fullwidth">
{{ sort_form.direction }}
</div>
<div>
<button class="button is-primary is-fullwidth" type="submit">
{% trans "Sort List" %}
</button>
</div>
</form>
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2> <h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block"> <form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
<div class="field has-addons"> <div class="field has-addons">
@ -93,7 +126,7 @@
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</section>
{% endif %} {% endif %}
</section>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -15,76 +15,9 @@
{% include 'moderation/report_preview.html' with report=report %} {% include 'moderation/report_preview.html' with report=report %}
</div> </div>
<div class="block columns"> {% include 'user_admin/user_info.html' with user=report.user %}
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "User details" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=report.user %}
{% if report.user.summary %}
<div class="box content has-background-white-ter is-shadowless">
{{ report.user.summary | to_markdown | safe }}
</div>
{% endif %}
<p class="mt-2"><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p> {% include 'user_admin/user_moderation_actions.html' with user=report.user %}
</div>
</div>
{% if not report.user.local %}
{% with server=report.user.federated_server %}
<div class="column is-half is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Instance details" %}</h4>
<div class="box content is-flex-grow-1">
{% if server %}
<h5>{{ server.server_name }}</h5>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>
<div class="box content has-background-white-ter is-shadowless">
{{ server.notes }}
</div>
{% endif %}
<p>
<a href="{% url 'settings-federated-server' server.id %}">{% trans "View instance" %}</a>
</p>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' report.user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="deactivate" method="post" action="{% url 'settings-report-deactivate' report.id %}">
{% csrf_token %}
{% if report.user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Deactivate user" %}</button>
{% else %}
<button class="button">{% trans "Reactivate user" %}</button>
{% endif %}
</form>
</div>
</div>
<div class="block"> <div class="block">
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3> <h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
@ -118,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

@ -30,7 +30,7 @@
</ul> </ul>
</div> </div>
{% include 'settings/user_admin_filters.html' %} {% include 'user_admin/user_admin_filters.html' %}
<div class="block"> <div class="block">
{% if not reports %} {% if not reports %}

View file

@ -123,7 +123,7 @@
{% include 'snippets/status_preview.html' with status=related_status %} {% include 'snippets/status_preview.html' with status=related_status %}
</div> </div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}"> <div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ related_status.published_date | post_date }} {{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %} {% include 'snippets/privacy-icons.html' with item=related_status %}
</div> </div>
</div> </div>

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

@ -4,18 +4,16 @@
{% with status.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}"> <form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}> <button class="button is-small is-light is-transparent" type="submit" {% if not status.boostable %}disabled{% endif %}>
<span class="icon icon-boost" title="{% trans 'Boost status' %}"> <span class="icon icon-boost m-0-mobile" title="{% trans 'Boost' %}"></span>
<span class="is-sr-only">{% trans "Boost status" %}</span> <span class="is-sr-only-mobile">{% trans "Boost" %}</span>
</span>
</button> </button>
</form> </form>
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}"> <form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small is-primary" type="submit"> <button class="button is-small is-light is-transparent" type="submit">
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}"> <span class="icon icon-boost has-text-primary m-0-mobile" title="{% trans 'Un-boost' %}"></span>
<span class="is-sr-only">{% trans "Un-boost status" %}</span> <span class="is-sr-only-mobile">{% trans "Un-boost" %}</span>
</span>
</button> </button>
</form> </form>
{% endwith %} {% endwith %}

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

@ -3,18 +3,17 @@
{% with status.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}"> <form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-small" type="submit"> <button class="button is-small is-light is-transparent" type="submit">
<span class="icon icon-heart" title="{% trans 'Like status' %}"> <span class="icon icon-heart m-0-mobile" title="{% trans 'Like' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span> </span>
<span class="is-sr-only-mobile">{% trans "Like" %}</span>
</button> </button>
</form> </form>
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}"> <form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
{% csrf_token %} {% csrf_token %}
<button class="button is-primary is-small" type="submit"> <button class="button is-light is-transparent is-small" type="submit">
<span class="icon icon-heart" title="{% trans 'Un-like status' %}"> <span class="icon icon-heart has-text-primary m-0-mobile" title="{% trans 'Un-like' %}"></span>
<span class="is-sr-only">{% trans "Un-like status" %}</span> <span class="is-sr-only-mobile">{% trans "Un-like" %}</span>
</span>
</button> </button>
</form> </form>
{% endwith %} {% endwith %}

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

@ -1,7 +1,7 @@
{% spaceless %} {% spaceless %}
{% load i18n %} {% load i18n %}
<p class="stars"> <span class="stars">
<span class="is-sr-only"> <span class="is-sr-only">
{% if rating %} {% if rating %}
{% blocktranslate trimmed with rating=rating|floatformat count counter=rating|length %} {% blocktranslate trimmed with rating=rating|floatformat count counter=rating|length %}
@ -23,5 +23,5 @@
aria-hidden="true" aria-hidden="true"
></span> ></span>
{% endfor %} {% endfor %}
</p> </span>
{% endspaceless %} {% endspaceless %}

View file

@ -1,14 +0,0 @@
{% load bookwyrm_tags %}
<div class="columns">
<div class="column is-narrow">
<div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
<div class="column">
<h3 class="title is-6">{% include 'snippets/book_titleby.html' with book=book %}</h3>
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
</div>
</div>

View file

@ -0,0 +1,135 @@
{% load bookwyrm_tags %}
{% load i18n %}
{% with status_type=status.status_type %}
<div
class="block"
{% if status_type == "Review" %}
itemprop="rating"
itemtype="https://schema.org/Rating"
{% endif %}
>
<div class="columns">
{% if not hide_book %}
{% with book=status.book|default:status.mention_books.first %}
{% if book %}
<div class="column is-narrow">
<div class="columns is-mobile">
<div class="column is-narrow">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
<div class="column is-hidden-tablet">
<p>{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:15 }}</p>
</div>
</div>
</div>
{% endif %}
{% endwith %}
{% endif %}
<article class="column">
{% if status_type == 'Review' %}
<header class="mb-2">
<h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }}
</h3>
<h4 class="subtitle is-6">
<span
class="is-hidden"
{% if status_type == "Review" %}
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
{% endif %}
>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
</h4>
</header>
{% endif %}
{% if status.content_warning %}
<div>
<p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div>
{% endif %}
<div
{% if status.content_warning %}
id="show-status-cw-{{ status.id }}"
class="is-hidden"
{% endif %}
>
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% with full=status.content|safe no_trim=status.content_warning itemprop="reviewBody" %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% endif %}
{% if status.attachments.exists %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a
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>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</article>
</div>
</div>
{% endwith %}

View file

@ -0,0 +1,23 @@
{% spaceless %}
{% load bookwyrm_tags %}
{% load i18n %}
{% if not hide_book %}
{% with book=status.book|default:status.mention_books.first %}
<div class="columns is-mobile">
<div class="column is-narrow">
<div>
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</div>
</div>
<div class="column">
<h3 class="title is-6 mb-1">{% include 'snippets/book_titleby.html' with book=book %}</h3>
<p>{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}</p>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
</div>
{% endwith %}
{% endif %}
{% endspaceless %}

View file

@ -0,0 +1,76 @@
{% extends 'components/card.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block card-header %}
<div class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status/status_header.html' with status=status %}
</div>
{% endblock %}
{% block card-content %}{% endblock %}
{% block card-footer %}
{% if moderation_mode and perms.bookwyrm.moderate_post %}
<div class="card-footer-item">
{# moderation options #}
<form name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}
</button>
</form>
</div>
{% elif no_interact %}
{# nothing here #}
{% elif request.user.is_authenticated %}
<div class="card-footer-item">
{% trans "Reply" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text=button_text icon_with_text="comment" class="is-small is-light is-transparent toggle-button" focus="id_content_reply" %}
</div>
<div class="card-footer-item">
{% include 'snippets/boost_button.html' with status=status %}
</div>
<div class="card-footer-item">
{% include 'snippets/fav_button.html' with status=status %}
</div>
{% if not moderation_mode %}
<div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small is-light is-transparent" right=True %}
</div>
{% endif %}
{% else %}
<div class="card-footer-item">
<a href="/login">
<span class="icon icon-comment is-small" title="{% trans 'Reply' %}">
<span class="is-sr-only">{% trans "Reply" %}</span>
</span>
<span class="icon icon-boost is-small ml-4" title="{% trans 'Boost status' %}">
<span class="is-sr-only">{% trans "Boost status" %}</span>
</span>
<span class="icon icon-heart is-small ml-4" title="{% trans 'Like status' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span>
</a>
</div>
{% endif %}
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %}
<section class="is-hidden" id="show-comment-{{ status.id }}">
<div class="card-footer">
<div class="card-footer-item">
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
</div>
</div>
</section>
{% endwith %}
{% endif %}
{% endblock %}

View file

@ -1,90 +1,14 @@
{% extends 'components/card.html' %} {% extends 'snippets/status/layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block card-header %}
<h3 class="card-header-title has-background-white-ter is-block">
{% include 'snippets/status/status_header.html' with status=status %}
</h3>
{% endblock %}
{% block card-content %} {% block card-content %}
{% include 'snippets/status/status_content.html' with status=status %} {% with status_type=status.status_type %}
{% endblock %}
{% block card-footer %}
<div class="card-footer-item">
{% if moderation_mode and perms.bookwyrm.moderate_post %}
{# moderation options #}
<form class="dropdown-item pt-0 pb-0" name="delete-{{status.id}}" action="/delete-status/{{ status.id }}" method="post">
{% csrf_token %}
<button class="button is-danger is-light" type="submit">
{% trans "Delete status" %}
</button>
</form>
{% elif no_interact %}
{# nothing here #}
{% elif request.user.is_authenticated %}
<div class="field has-addons">
<div class="control">
{% trans "Reply" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with controls_text="show-comment" controls_uid=status.id text=button_text icon="comment" class="is-small toggle-button" focus="id_content_reply" %}
</div>
<div class="control">
{% include 'snippets/boost_button.html' with status=status %}
</div>
<div class="control">
{% include 'snippets/fav_button.html' with status=status %}
</div>
</div>
{% if status_type == 'GeneratedNote' or status_type == 'Rating' %}
{% include 'snippets/status/generated_status.html' with status=status %}
{% else %} {% else %}
<a href="/login"> {% include 'snippets/status/content_status.html' with status=status %}
<span class="icon icon-comment" title="{% trans 'Reply' %}">
<span class="is-sr-only">{% trans "Reply" %}</span>
</span>
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
<span class="is-sr-only">{% trans "Boost status" %}</span>
</span>
<span class="icon icon-heart" title="{% trans 'Like status' %}">
<span class="is-sr-only">{% trans "Like status" %}</span>
</span>
</a>
{% endif %} {% endif %}
</div>
<div class="card-footer-item">
{% include 'snippets/privacy-icons.html' with item=status %}
</div>
<div class="card-footer-item">
<a href="{{ status.remote_id }}">{{ status.published_date | post_date }}</a>
</div>
{% if not moderation_mode %}
<div class="card-footer-item">
{% include 'snippets/status/status_options.html' with class="is-small" right=True %}
</div>
{% endif %}
{% endblock %}
{% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %}
<section class="is-hidden" id="show-comment-{{ status.id }}">
<div class="card-footer">
<div class="card-footer-item">
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}
</div>
</div>
</section>
{% endwith %} {% endwith %}
{% endif %}
{% endblock %} {% endblock %}

View file

@ -1,137 +0,0 @@
{% spaceless %}
{% load bookwyrm_tags %}
{% load i18n %}
{% 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>
{% if status.name %}
<h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }}
</h3>
{% 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 %}
</div>
{% endif %}
{% if status.content_warning %}
<div>
<p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div>
{% endif %}
<div
{% if status.content_warning %}
id="show-status-cw-{{ status.id }}"
class="is-hidden"
{% endif %}
>
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% with full=status.content|safe no_trim=status.content_warning itemprop=body_prop %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% endif %}
{% if status.attachments.exists %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a
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>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% if not hide_book %}
{% if status.book or status.mention_books.count %}
<div
{% if status_type != 'GeneratedNote' %}
class="box has-background-white-bis"
{% endif %}
>
{% if status.book %}
{% with book=status.book %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% elif status.mention_books.count %}
{% with book=status.mention_books.first %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% endif %}
{% endwith %}
{% endspaceless %}

View file

@ -1,16 +1,29 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
{% load humanize %}
<div class="media">
<figure class="media-left" aria-hidden="true">
<a class="image is-48x48" href="{{ status.user.local_path }}">
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" medium="true" %}
</a>
</figure>
<div class="media-content">
<h3 class="has-text-weight-bold">
<span <span
itemprop="author" itemprop="author"
itemscope itemscope
itemtype="https://schema.org/Person" itemtype="https://schema.org/Person"
> >
{% if status.user.avatar %}
<meta itemprop="image" content="/images/{{ status.user.avatar }}">
{% endif %}
<a <a
href="{{ status.user.local_path }}" href="{{ status.user.local_path }}"
itemprop="url" itemprop="url"
> >
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
<span itemprop="name">{{ status.user.display_name }}</span> <span itemprop="name">{{ status.user.display_name }}</span>
</a> </a>
</span> </span>
@ -28,26 +41,68 @@
{% elif status.reply_parent %} {% elif status.reply_parent %}
{% with parent_status=status|parent %} {% with parent_status=status|parent %}
{% if parent_status.status_type == 'Review' %}
{% 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 %}
{% elif parent_status.status_type == 'Comment' %}
{% 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 %}
{% elif parent_status.status_type == 'Quotation' %}
{% 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 %}
{% else %}
{% 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 %} {% 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 %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% if status.book %} {% if status.book %}
<a href="/book/{{ status.book.id }}">{{ status.book.title }}</a> {% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
<span
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
>
<span class="is-hidden" {{ rating_type }}>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{% if status.book %}
{% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
<span
itemprop="reviewRating"
itemscope
itemtype="https://schema.org/Rating"
>
<span class="is-hidden" {{ rating_type }}>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
{% endif %}
{% else %}
{% include 'snippets/book_titleby.html' with book=status.book %}
{% endif %}
{% elif status.mention_books %} {% elif status.mention_books %}
<a href="/book/{{ status.mention_books.first.id }}">{{ status.mention_books.first.title }}</a> <a href="/book/{{ status.mention_books.first.id }}">
{{ status.mention_books.first.title }}
</a>
{% 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 %} {% endif %}
</h3>
<p class="is-size-7 is-flex is-align-items-center">
<a href="{{ status.remote_id }}">{{ status.published_date|timesince }}</a>
{% if status.progress %} {% if status.progress %}
<p class="help"> <span class="ml-1">
({% if status.progress_mode == 'PG' %}{% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %}{% else %}{{ status.progress }}%{% endif %}) {% if status.progress_mode == 'PG' %}
</p> ({% include 'snippets/page_text.html' with page=status.progress total_pages=status.book.pages %})
{% else %}
({{ status.progress }}%)
{% endif %} {% endif %}
</span>
{% endif %}
{% include 'snippets/privacy-icons.html' with item=status %}
</p>
</div>
</div>

View file

@ -3,27 +3,26 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% block dropdown-trigger %} {% block dropdown-trigger %}
<span class="icon icon-dots-three"> <span class="icon icon-dots-three m-0-mobile"></span>
<span class="is-sr-only">{% trans "More options" %}</span> <span class="is-sr-only-mobile">{% trans "More options" %}</span>
</span>
{% endblock %} {% endblock %}
{% 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>
@ -31,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

@ -10,9 +10,12 @@
> >
{% if icon %} {% if icon %}
<span class="icon icon-{{ icon }}" title="{{ text }}"> <span class="icon icon-{{ icon }} m-0-mobile" title="{{ text }}">
<span class="is-sr-only">{{ text }}</span> <span class="is-sr-only">{{ text }}</span>
</span> </span>
{% elif icon_with_text %}
<span class="icon icon-{{ icon_with_text }} m-0-mobile" title="{{ text }}"></span>
<span class="is-sr-only-mobile">{{ text }}</span>
{% else %} {% else %}
<span>{{ text }}</span> <span>{{ text }}</span>
{% endif %} {% endif %}

View file

@ -1,11 +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:150 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 }}">
<div class="content" id="trimmed-{{ uuid }}"> <div class="content" id="trimmed-{{ uuid }}">
@ -46,4 +45,3 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endspaceless %}

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

@ -68,10 +68,9 @@
<div class="block"> <div class="block">
<div> <div>
{% if books|length > 0 %} {% if books|length > 0 %}
<div class="scroll-x"> <table class="table is-striped is-fullwidth is-mobile">
<table class="table is-striped is-fullwidth"> <thead>
<tr>
<tr class="book-preview">
<th>{% trans "Cover" %}</th> <th>{% trans "Cover" %}</th>
<th>{% trans "Title" %}</th> <th>{% trans "Title" %}</th>
<th>{% trans "Author" %}</th> <th>{% trans "Author" %}</th>
@ -83,34 +82,37 @@
<th aria-hidden="true"></th> <th aria-hidden="true"></th>
{% endif %} {% endif %}
</tr> </tr>
</thead>
<tbody>
{% for book in books %} {% for book in books %}
{% spaceless %}
<tr class="book-preview"> <tr class="book-preview">
<td> <td class="book-preview-top-row">
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a> <a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book size="small" %}</a>
</td> </td>
<td> <td data-title="{% trans "Title" %}">
<a href="{{ book.local_path }}">{{ book.title }}</a> <a href="{{ book.local_path }}">{{ book.title }}</a>
</td> </td>
<td> <td data-title="{% trans "Author" %}">
{% include 'snippets/authors.html' %} {% include 'snippets/authors.html' %}
</td> </td>
<td> <td data-title="{% trans "Shelved" %}">
{{ book.created_date | naturalday }} {{ book.created_date | naturalday }}
</td> </td>
{% latest_read_through book user as read_through %} {% latest_read_through book user as read_through %}
<td> <td data-title="{% trans "Started" %}">
{{ read_through.start_date | naturalday |default_if_none:""}} {{ read_through.start_date | naturalday |default_if_none:""}}
</td> </td>
<td> <td data-title="{% trans "Finished" %}">
{{ read_through.finish_date | naturalday |default_if_none:""}} {{ read_through.finish_date | naturalday |default_if_none:""}}
</td> </td>
{% if ratings %} {% if ratings %}
<td> <td data-title="{% trans "Rating" %}">
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %} {% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
</td> </td>
{% endif %} {% endif %}
{% if shelf.user == request.user %} {% if shelf.user == request.user %}
<td> <td class="book-preview-top-row has-text-right">
{% with right=True %} {% with right=True %}
{% if not shelf.id %} {% if not shelf.id %}
{% active_shelf book as current %} {% active_shelf book as current %}
@ -122,9 +124,10 @@
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
{% endspaceless %}
{% endfor %} {% endfor %}
</tbody>
</table> </table>
</div>
{% else %} {% else %}
<p>{% trans "This shelf is empty." %}</p> <p>{% trans "This shelf is empty." %}</p>
{% if shelf.id and shelf.editable %} {% if shelf.id and shelf.editable %}

View file

@ -0,0 +1,19 @@
{% extends 'settings/admin_layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load humanize %}
{% block title %}{{ user.username }}{% endblock %}
{% block header %}{{ user.username }}{% endblock %}
{% block panel %}
<div class="block">
<a href="{% url 'settings-users' %}">{% trans "Back to users" %}</a>
</div>
{% include 'user_admin/user_info.html' with user=user %}
{% include 'user_admin/user_moderation_actions.html' with user=user %}
{% endblock %}

View file

@ -13,7 +13,7 @@
{% block panel %} {% block panel %}
{% include 'settings/user_admin_filters.html' %} {% include 'user_admin/user_admin_filters.html' %}
<table class="table is-striped"> <table class="table is-striped">
<tr> <tr>
@ -41,7 +41,7 @@
</tr> </tr>
{% for user in users %} {% for user in users %}
<tr> <tr>
<td>{{ user.username }}</td> <td><a href="{% url 'settings-user' user.id %}">{{ user.username }}</a></td>
<td>{{ user.created_date }}</td> <td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td> <td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td> <td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>

View file

@ -1,6 +1,6 @@
{% extends 'snippets/filters_panel/filters_panel.html' %} {% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %} {% block filter_fields %}
{% include 'settings/server_filter.html' %} {% include 'user_admin/server_filter.html' %}
{% include 'settings/username_filter.html' %} {% include 'user_admin/username_filter.html' %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,56 @@
{% load i18n %}
{% load bookwyrm_tags %}
<div class="block columns">
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "User details" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=user %}
{% if user.summary %}
<div class="box content has-background-white-ter is-shadowless">
{{ user.summary | to_markdown | safe }}
</div>
{% endif %}
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
</div>
</div>
{% if not user.local %}
{% with server=user.federated_server %}
<div class="column is-half is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Instance details" %}</h4>
<div class="box content is-flex-grow-1">
{% if server %}
<h5>{{ server.server_name }}</h5>
<dl>
<div class="is-flex">
<dt>{% trans "Software:" %}</dt>
<dd>{{ server.application_type }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Version:" %}</dt>
<dd>{{ server.application_version }}</dd>
</div>
<div class="is-flex">
<dt>{% trans "Status:" %}</dt>
<dd>{{ server.status }}</dd>
</div>
</dl>
{% if server.notes %}
<h5>{% trans "Notes" %}</h5>
<div class="box content has-background-white-ter is-shadowless">
{{ server.notes }}
</div>
{% endif %}
<p>
<a href="{% url 'settings-federated-server' server.id %}">{% trans "View instance" %}</a>
</p>
{% else %}
<em>{% trans "Not set" %}</em>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</div>

View file

@ -0,0 +1,42 @@
{% load i18n %}
<div class="block content">
<h3>{% trans "Actions" %}</h3>
<div class="is-flex">
<p class="mr-1">
<a class="button" href="{% url 'direct-messages-user' user.username %}">{% trans "Send direct message" %}</a>
</p>
<form name="suspend" method="post" action="{% url 'settings-report-suspend' user.id %}">
{% csrf_token %}
{% if user.is_active %}
<button type="submit" class="button is-danger is-light">{% trans "Suspend user" %}</button>
{% else %}
<button class="button">{% trans "Un-suspend user" %}</button>
{% endif %}
</form>
</div>
{% if user.local %}
<div>
<form name="permission" method="post" action="{% url 'settings-user' user.id %}">
{% csrf_token %}
<label class="label" for="id_user_group">{% trans "Access level:" %}</label>
{% if group_form.non_field_errors %}
{{ group_form.non_field_errors }}
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>{{ name|title }}</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>User</option>
</select>
</div>
{% for error in group_form.groups.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% endwith %}
<button class="button">{% trans "Save" %}</button>
</form>
</div>
{% endif %}
</div>

View file

@ -1,11 +1,8 @@
""" template filters """ """ template filters """
from uuid import uuid4 from uuid import uuid4
from datetime import datetime
from dateutil.relativedelta import relativedelta from django import template, utils
from django import template
from django.db.models import Avg from django.db.models import Avg
from django.utils import timezone
from bookwyrm import models, views from bookwyrm import models, views
from bookwyrm.views.status import to_markdown from bookwyrm.views.status import to_markdown
@ -62,14 +59,10 @@ def get_notification_count(user):
def get_replies(status): def get_replies(status):
"""get all direct replies to a status""" """get all direct replies to a status"""
# TODO: this limit could cause problems # TODO: this limit could cause problems
return ( return models.Status.objects.filter(
models.Status.objects.filter(
reply_parent=status, reply_parent=status,
deleted=False, deleted=False,
) ).select_subclasses()[:10]
.select_subclasses()
.all()[:10]
)
@register.filter(name="parent") @register.filter(name="parent")
@ -133,28 +126,6 @@ def get_uuid(identifier):
return "%s%s" % (identifier, uuid4()) return "%s%s" % (identifier, uuid4())
@register.filter(name="post_date")
def time_since(date):
""" concise time ago function """
if not isinstance(date, datetime):
return ""
now = timezone.now()
if date < (now - relativedelta(weeks=1)):
formatter = "%b %-d"
if date.year != now.year:
formatter += " %Y"
return date.strftime(formatter)
delta = relativedelta(now, date)
if delta.days:
return "%dd" % delta.days
if delta.hours:
return "%dh" % delta.hours
if delta.minutes:
return "%dm" % delta.minutes
return "%ds" % delta.seconds
@register.filter(name="to_markdown") @register.filter(name="to_markdown")
def get_markdown(content): def get_markdown(content):
"""convert markdown to html""" """convert markdown to html"""
@ -197,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"""
@ -246,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

@ -51,6 +51,7 @@ class List(TestCase):
book_list=book_list, book_list=book_list,
book=self.book, book=self.book,
user=self.local_user, user=self.local_user,
order=1,
) )
self.assertTrue(item.approved) self.assertTrue(item.approved)
@ -65,7 +66,11 @@ class List(TestCase):
) )
item = models.ListItem.objects.create( item = models.ListItem.objects.create(
book_list=book_list, book=self.book, user=self.local_user, approved=False book_list=book_list,
book=self.book,
user=self.local_user,
approved=False,
order=1,
) )
self.assertFalse(item.approved) self.assertFalse(item.approved)

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

@ -181,36 +181,6 @@ class TemplateTags(TestCase):
uuid = bookwyrm_tags.get_uuid("hi") uuid = bookwyrm_tags.get_uuid("hi")
self.assertTrue(re.match(r"hi[A-Za-z0-9\-]", uuid)) self.assertTrue(re.match(r"hi[A-Za-z0-9\-]", uuid))
def test_time_since(self, _):
""" ultraconcise timestamps """
self.assertEqual(bookwyrm_tags.time_since("bleh"), "")
now = timezone.now()
self.assertEqual(bookwyrm_tags.time_since(now), "0s")
seconds_ago = now - relativedelta(seconds=4)
self.assertEqual(bookwyrm_tags.time_since(seconds_ago), "4s")
minutes_ago = now - relativedelta(minutes=8)
self.assertEqual(bookwyrm_tags.time_since(minutes_ago), "8m")
hours_ago = now - relativedelta(hours=9)
self.assertEqual(bookwyrm_tags.time_since(hours_ago), "9h")
days_ago = now - relativedelta(days=3)
self.assertEqual(bookwyrm_tags.time_since(days_ago), "3d")
# I am not going to figure out how to mock dates tonight.
months_ago = now - relativedelta(months=5)
self.assertTrue(
re.match(r"[A-Z][a-z]{2} \d?\d", bookwyrm_tags.time_since(months_ago))
)
years_ago = now - relativedelta(years=10)
self.assertTrue(
re.match(r"[A-Z][a-z]{2} \d?\d \d{4}", bookwyrm_tags.time_since(years_ago))
)
def test_get_markdown(self, _): def test_get_markdown(self, _):
"""mardown format data""" """mardown format data"""
result = bookwyrm_tags.get_markdown("_hi_") result = bookwyrm_tags.get_markdown("_hi_")

View file

@ -94,6 +94,7 @@ class InboxAdd(TestCase):
"type": "ListItem", "type": "ListItem",
"book": self.book.remote_id, "book": self.book.remote_id,
"id": "https://bookwyrm.social/listbook/6189", "id": "https://bookwyrm.social/listbook/6189",
"order": 1,
}, },
"target": "https://bookwyrm.social/user/mouse/list/to-read", "target": "https://bookwyrm.social/user/mouse/list/to-read",
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",

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

@ -80,6 +80,7 @@ class InboxRemove(TestCase):
user=self.local_user, user=self.local_user,
book=self.book, book=self.book,
book_list=booklist, book_list=booklist,
order=1,
) )
self.assertEqual(booklist.books.count(), 1) self.assertEqual(booklist.books.count(), 1)

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

@ -39,6 +39,25 @@ class ListViews(TestCase):
remote_id="https://example.com/book/1", remote_id="https://example.com/book/1",
parent_work=work, parent_work=work,
) )
work_two = models.Work.objects.create(title="Labori")
self.book_two = models.Edition.objects.create(
title="Example Edition 2",
remote_id="https://example.com/book/2",
parent_work=work_two,
)
work_three = models.Work.objects.create(title="Trabajar")
self.book_three = models.Edition.objects.create(
title="Example Edition 3",
remote_id="https://example.com/book/3",
parent_work=work_three,
)
work_four = models.Work.objects.create(title="Travailler")
self.book_four = models.Edition.objects.create(
title="Example Edition 4",
remote_id="https://example.com/book/4",
parent_work=work_four,
)
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
self.list = models.List.objects.create( self.list = models.List.objects.create(
name="Test List", user=self.local_user name="Test List", user=self.local_user
@ -194,6 +213,7 @@ class ListViews(TestCase):
user=self.local_user, user=self.local_user,
book=self.book, book=self.book,
approved=False, approved=False,
order=1,
) )
request = self.factory.post( request = self.factory.post(
@ -208,7 +228,7 @@ class ListViews(TestCase):
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock: with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
view(request, self.list.id) view(request, self.list.id)
self.assertEqual(mock.call_count, 1) self.assertEqual(mock.call_count, 2)
activity = json.loads(mock.call_args[0][1]) activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Add") self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id) self.assertEqual(activity["actor"], self.local_user.remote_id)
@ -228,6 +248,7 @@ class ListViews(TestCase):
user=self.local_user, user=self.local_user,
book=self.book, book=self.book,
approved=False, approved=False,
order=1,
) )
request = self.factory.post( request = self.factory.post(
@ -268,6 +289,261 @@ class ListViews(TestCase):
self.assertEqual(item.user, self.local_user) self.assertEqual(item.user, self.local_user)
self.assertTrue(item.approved) self.assertTrue(item.approved)
def test_add_two_books(self):
"""
Putting two books on the list. The first should have an order value of
1 and the second should have an order value of 2.
"""
request_one = self.factory.post(
"",
{
"book": self.book.id,
"list": self.list.id,
},
)
request_one.user = self.local_user
request_two = self.factory.post(
"",
{
"book": self.book_two.id,
"list": self.list.id,
},
)
request_two.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.list.add_book(request_one)
views.list.add_book(request_two)
items = self.list.listitem_set.order_by("order").all()
self.assertEqual(items[0].book, self.book)
self.assertEqual(items[1].book, self.book_two)
self.assertEqual(items[0].order, 1)
self.assertEqual(items[1].order, 2)
def test_add_three_books_and_remove_second(self):
"""
Put three books on a list and then remove the one in the middle. The
ordering of the list should adjust to not have a gap.
"""
request_one = self.factory.post(
"",
{
"book": self.book.id,
"list": self.list.id,
},
)
request_one.user = self.local_user
request_two = self.factory.post(
"",
{
"book": self.book_two.id,
"list": self.list.id,
},
)
request_two.user = self.local_user
request_three = self.factory.post(
"",
{
"book": self.book_three.id,
"list": self.list.id,
},
)
request_three.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.list.add_book(request_one)
views.list.add_book(request_two)
views.list.add_book(request_three)
items = self.list.listitem_set.order_by("order").all()
self.assertEqual(items[0].book, self.book)
self.assertEqual(items[1].book, self.book_two)
self.assertEqual(items[2].book, self.book_three)
self.assertEqual(items[0].order, 1)
self.assertEqual(items[1].order, 2)
self.assertEqual(items[2].order, 3)
remove_request = self.factory.post("", {"item": items[1].id})
remove_request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.list.remove_book(remove_request, self.list.id)
items = self.list.listitem_set.order_by("order").all()
self.assertEqual(items[0].book, self.book)
self.assertEqual(items[1].book, self.book_three)
self.assertEqual(items[0].order, 1)
self.assertEqual(items[1].order, 2)
def test_adding_book_with_a_pending_book(self):
"""
When a list contains any pending books, the pending books should have
be at the end of the list by order. If a book is added while a book is
pending, its order should precede the pending books.
"""
request = self.factory.post(
"",
{
"book": self.book_three.id,
"list": self.list.id,
},
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
models.ListItem.objects.create(
book_list=self.list,
user=self.rat,
book=self.book_two,
approved=False,
order=2,
)
views.list.add_book(request)
items = self.list.listitem_set.order_by("order").all()
self.assertEqual(items[0].book, self.book)
self.assertEqual(items[0].order, 1)
self.assertTrue(items[0].approved)
self.assertEqual(items[1].book, self.book_three)
self.assertEqual(items[1].order, 2)
self.assertTrue(items[1].approved)
self.assertEqual(items[2].book, self.book_two)
self.assertEqual(items[2].order, 3)
self.assertFalse(items[2].approved)
def test_approving_one_pending_book_from_multiple(self):
"""
When a list contains any pending books, the pending books should have
be at the end of the list by order. If a pending book is approved, then
its order should be at the end of the approved books and before the
remaining pending books.
"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book_two,
approved=True,
order=2,
)
models.ListItem.objects.create(
book_list=self.list,
user=self.rat,
book=self.book_three,
approved=False,
order=3,
)
to_be_approved = models.ListItem.objects.create(
book_list=self.list,
user=self.rat,
book=self.book_four,
approved=False,
order=4,
)
view = views.Curate.as_view()
request = self.factory.post(
"",
{
"item": to_be_approved.id,
"approved": "true",
},
)
request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
view(request, self.list.id)
items = self.list.listitem_set.order_by("order").all()
self.assertEqual(items[0].book, self.book)
self.assertEqual(items[0].order, 1)
self.assertTrue(items[0].approved)
self.assertEqual(items[1].book, self.book_two)
self.assertEqual(items[1].order, 2)
self.assertTrue(items[1].approved)
self.assertEqual(items[2].book, self.book_four)
self.assertEqual(items[2].order, 3)
self.assertTrue(items[2].approved)
self.assertEqual(items[3].book, self.book_three)
self.assertEqual(items[3].order, 4)
self.assertFalse(items[3].approved)
def test_add_three_books_and_move_last_to_first(self):
"""
Put three books on the list and move the last book to the first
position.
"""
request_one = self.factory.post(
"",
{
"book": self.book.id,
"list": self.list.id,
},
)
request_one.user = self.local_user
request_two = self.factory.post(
"",
{
"book": self.book_two.id,
"list": self.list.id,
},
)
request_two.user = self.local_user
request_three = self.factory.post(
"",
{
"book": self.book_three.id,
"list": self.list.id,
},
)
request_three.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.list.add_book(request_one)
views.list.add_book(request_two)
views.list.add_book(request_three)
items = self.list.listitem_set.order_by("order").all()
self.assertEqual(items[0].book, self.book)
self.assertEqual(items[1].book, self.book_two)
self.assertEqual(items[2].book, self.book_three)
self.assertEqual(items[0].order, 1)
self.assertEqual(items[1].order, 2)
self.assertEqual(items[2].order, 3)
set_position_request = self.factory.post("", {"position": 1})
set_position_request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.list.set_book_position(set_position_request, items[2].id)
items = self.list.listitem_set.order_by("order").all()
self.assertEqual(items[0].book, self.book_three)
self.assertEqual(items[1].book, self.book)
self.assertEqual(items[2].book, self.book_two)
self.assertEqual(items[0].order, 1)
self.assertEqual(items[1].order, 2)
self.assertEqual(items[2].order, 3)
def test_add_book_outsider(self): def test_add_book_outsider(self):
"""put a book on a list""" """put a book on a list"""
self.list.curation = "open" self.list.curation = "open"
@ -358,6 +634,7 @@ class ListViews(TestCase):
book_list=self.list, book_list=self.list,
user=self.local_user, user=self.local_user,
book=self.book, book=self.book,
order=1,
) )
self.assertTrue(self.list.listitem_set.exists()) self.assertTrue(self.list.listitem_set.exists())
@ -377,9 +654,7 @@ class ListViews(TestCase):
"""take an item off a list""" """take an item off a list"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
item = models.ListItem.objects.create( item = models.ListItem.objects.create(
book_list=self.list, book_list=self.list, user=self.local_user, book=self.book, order=1
user=self.local_user,
book=self.book,
) )
self.assertTrue(self.list.listitem_set.exists()) self.assertTrue(self.list.listitem_set.exists())
request = self.factory.post( request = self.factory.post(

View file

@ -1,5 +1,4 @@
""" test for app action functionality """ """ test for app action functionality """
from unittest.mock import patch
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -115,22 +114,19 @@ class ReportViews(TestCase):
report.refresh_from_db() report.refresh_from_db()
self.assertFalse(report.resolved) self.assertFalse(report.resolved)
def test_deactivate_user(self): def test_suspend_user(self):
"""toggle whether a user is able to log in""" """toggle whether a user is able to log in"""
self.assertTrue(self.rat.is_active) self.assertTrue(self.rat.is_active)
report = models.Report.objects.create(reporter=self.local_user, user=self.rat)
request = self.factory.post("") request = self.factory.post("")
request.user = self.local_user request.user = self.local_user
request.user.is_superuser = True request.user.is_superuser = True
# de-activate # de-activate
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): views.suspend_user(request, self.rat.id)
views.deactivate_user(request, report.id)
self.rat.refresh_from_db() self.rat.refresh_from_db()
self.assertFalse(self.rat.is_active) self.assertFalse(self.rat.is_active)
# re-activate # re-activate
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): views.suspend_user(request, self.rat.id)
views.deactivate_user(request, report.id)
self.rat.refresh_from_db() self.rat.refresh_from_db()
self.assertTrue(self.rat.is_active) self.assertTrue(self.rat.is_active)

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

@ -30,6 +30,14 @@ class UserViews(TestCase):
self.rat = models.User.objects.create_user( self.rat = models.User.objects.create_user(
"rat@local.com", "rat@rat.rat", "password", local=True, localname="rat" "rat@local.com", "rat@rat.rat", "password", local=True, localname="rat"
) )
self.book = models.Edition.objects.create(title="test")
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
models.ShelfBook.objects.create(
book=self.book,
user=self.local_user,
shelf=self.local_user.shelf_set.first(),
)
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
self.anonymous_user = AnonymousUser self.anonymous_user = AnonymousUser
self.anonymous_user.is_authenticated = False self.anonymous_user.is_authenticated = False

View file

@ -1,4 +1,6 @@
""" test for app action functionality """ """ test for app action functionality """
from unittest.mock import patch
from django.contrib.auth.models import Group
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -21,9 +23,9 @@ class UserAdminViews(TestCase):
) )
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
def test_user_admin_page(self): def test_user_admin_list_page(self):
"""there are so many views, this just makes sure it LOADS""" """there are so many views, this just makes sure it LOADS"""
view = views.UserAdmin.as_view() view = views.UserAdminList.as_view()
request = self.factory.get("") request = self.factory.get("")
request.user = self.local_user request.user = self.local_user
request.user.is_superuser = True request.user.is_superuser = True
@ -31,3 +33,38 @@ class UserAdminViews(TestCase):
self.assertIsInstance(result, TemplateResponse) self.assertIsInstance(result, TemplateResponse)
result.render() result.render()
self.assertEqual(result.status_code, 200) self.assertEqual(result.status_code, 200)
def test_user_admin_page(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.UserAdmin.as_view()
request = self.factory.get("")
request.user = self.local_user
request.user.is_superuser = True
result = view(request, self.local_user.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)
def test_user_admin_page_post(self):
"""set the user's group"""
group = Group.objects.create(name="editor")
self.assertEqual(
list(self.local_user.groups.values_list("name", flat=True)), []
)
view = views.UserAdmin.as_view()
request = self.factory.post("", {"groups": [group.id]})
request.user = self.local_user
request.user.is_superuser = True
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
result = view(request, self.local_user.id)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(
list(self.local_user.groups.values_list("name", flat=True)), ["editor"]
)

View file

@ -51,13 +51,20 @@ urlpatterns = [
r"^password-reset/(?P<code>[A-Za-z0-9]+)/?$", views.PasswordReset.as_view() r"^password-reset/(?P<code>[A-Za-z0-9]+)/?$", views.PasswordReset.as_view()
), ),
# admin # admin
re_path(r"^settings/site-settings", views.Site.as_view(), name="settings-site"), re_path(r"^settings/site-settings/?$", views.Site.as_view(), name="settings-site"),
re_path( re_path(
r"^settings/email-preview", r"^settings/email-preview/?$",
views.site.email_preview, views.site.email_preview,
name="settings-email-preview", name="settings-email-preview",
), ),
re_path(r"^settings/users", views.UserAdmin.as_view(), name="settings-users"), re_path(
r"^settings/users/?$", views.UserAdminList.as_view(), name="settings-users"
),
re_path(
r"^settings/users/(?P<user>\d+)/?$",
views.UserAdmin.as_view(),
name="settings-user",
),
re_path( re_path(
r"^settings/federation/?$", r"^settings/federation/?$",
views.Federation.as_view(), views.Federation.as_view(),
@ -113,9 +120,9 @@ urlpatterns = [
name="settings-report", name="settings-report",
), ),
re_path( re_path(
r"^settings/reports/(?P<report_id>\d+)/deactivate/?$", r"^settings/reports/(?P<user_id>\d+)/suspend/?$",
views.deactivate_user, views.suspend_user,
name="settings-report-deactivate", name="settings-report-suspend",
), ),
re_path( re_path(
r"^settings/reports/(?P<report_id>\d+)/resolve/?$", r"^settings/reports/(?P<report_id>\d+)/resolve/?$",
@ -184,6 +191,11 @@ urlpatterns = [
views.list.remove_book, views.list.remove_book,
name="list-remove-book", name="list-remove-book",
), ),
re_path(
r"^list-item/(?P<list_item_id>\d+)/set-position$",
views.list.set_book_position,
name="list-set-book-position",
),
re_path( re_path(
r"^list/(?P<list_id>\d+)/curate/?$", views.Curate.as_view(), name="list-curate" r"^list/(?P<list_id>\d+)/curate/?$", views.Curate.as_view(), name="list-curate"
), ),
@ -248,7 +260,12 @@ urlpatterns = [
re_path(r"^boost/(?P<status_id>\d+)/?$", views.Boost.as_view()), re_path(r"^boost/(?P<status_id>\d+)/?$", views.Boost.as_view()),
re_path(r"^unboost/(?P<status_id>\d+)/?$", views.Unboost.as_view()), re_path(r"^unboost/(?P<status_id>\d+)/?$", views.Unboost.as_view()),
# books # books
re_path(r"%s(.json)?/?$" % book_path, views.Book.as_view()), re_path(r"%s(.json)?/?$" % book_path, views.Book.as_view(), name="book"),
re_path(
r"%s/(?P<user_statuses>review|comment|quote)/?$" % book_path,
views.Book.as_view(),
name="book-user-statuses",
),
re_path(r"%s/edit/?$" % book_path, views.EditBook.as_view()), re_path(r"%s/edit/?$" % book_path, views.EditBook.as_view()),
re_path(r"%s/confirm/?$" % book_path, views.ConfirmEditBook.as_view()), re_path(r"%s/confirm/?$" % book_path, views.ConfirmEditBook.as_view()),
re_path(r"^create-book/?$", views.EditBook.as_view()), re_path(r"^create-book/?$", views.EditBook.as_view()),
@ -265,11 +282,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

@ -25,7 +25,7 @@ from .notifications import Notifications
from .outbox import Outbox from .outbox import Outbox
from .reading import edit_readthrough, create_readthrough, delete_readthrough from .reading import edit_readthrough, create_readthrough, delete_readthrough
from .reading import start_reading, finish_reading, delete_progressupdate from .reading import start_reading, finish_reading, delete_progressupdate
from .reports import Report, Reports, make_report, resolve_report, deactivate_user from .reports import Report, Reports, make_report, resolve_report, suspend_user
from .rss_feed import RssFeed from .rss_feed import RssFeed
from .password import PasswordResetRequest, PasswordReset, ChangePassword from .password import PasswordResetRequest, PasswordReset, ChangePassword
from .search import Search from .search import Search
@ -34,8 +34,7 @@ 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 from .user_admin import UserAdmin, UserAdminList
from .wellknown import * from .wellknown import *

View file

@ -28,13 +28,8 @@ from .helpers import is_api_request, get_edition, privacy_filter
class Book(View): class Book(View):
"""a book! this is the stuff""" """a book! this is the stuff"""
def get(self, request, book_id): def get(self, request, book_id, user_statuses=False):
"""info about a book""" """info about a book"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
try: try:
book = models.Book.objects.select_subclasses().get(id=book_id) book = models.Book.objects.select_subclasses().get(id=book_id)
except models.Book.DoesNotExist: except models.Book.DoesNotExist:
@ -45,29 +40,41 @@ class Book(View):
if isinstance(book, models.Work): if isinstance(book, models.Work):
book = book.get_default_edition() book = book.get_default_edition()
if not book: if not book or not book.parent_work:
return HttpResponseNotFound() return HttpResponseNotFound()
work = book.parent_work work = book.parent_work
if not work:
return HttpResponseNotFound()
# all reviews for the book # all reviews for the book
reviews = models.Review.objects.filter(book__in=work.editions.all()) reviews = privacy_filter(
reviews = privacy_filter(request.user, reviews) request.user, models.Review.objects.filter(book__in=work.editions.all())
)
# the reviews to show # the reviews to show
paginated = Paginator( if user_statuses and request.user.is_authenticated:
reviews.exclude(Q(content__isnull=True) | Q(content="")), PAGE_LENGTH if user_statuses == "review":
) queryset = book.review_set
reviews_page = paginated.get_page(page) elif user_statuses == "comment":
queryset = book.comment_set
else:
queryset = book.quotation_set
queryset = queryset.filter(user=request.user)
else:
queryset = reviews.exclude(Q(content__isnull=True) | Q(content=""))
paginated = Paginator(queryset, PAGE_LENGTH)
data = {
"book": book,
"statuses": paginated.get_page(request.GET.get("page")),
"review_count": reviews.count(),
"ratings": reviews.filter(Q(content__isnull=True) | Q(content="")),
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
"lists": privacy_filter(
request.user, book.list_set.filter(listitem__approved=True)
),
}
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,
@ -77,31 +84,24 @@ class Book(View):
readthrough.progress_updates = ( readthrough.progress_updates = (
readthrough.progressupdate_set.all().order_by("-updated_date") readthrough.progressupdate_set.all().order_by("-updated_date")
) )
data["readthroughs"] = readthroughs
user_shelves = models.ShelfBook.objects.filter(user=request.user, book=book) data["user_shelves"] = models.ShelfBook.objects.filter(
user=request.user, book=book
)
other_edition_shelves = models.ShelfBook.objects.filter( data["other_edition_shelves"] = models.ShelfBook.objects.filter(
~Q(book=book), ~Q(book=book),
user=request.user, user=request.user,
book__parent_work=book.parent_work, book__parent_work=book.parent_work,
) )
data = { data["user_statuses"] = {
"book": book, "review_count": book.review_set.filter(user=request.user).count(),
"reviews": reviews_page, "comment_count": book.comment_set.filter(user=request.user).count(),
"review_count": reviews.count(), "quotation_count": book.quotation_set.filter(user=request.user).count(),
"ratings": reviews.filter(Q(content__isnull=True) | Q(content="")),
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
"tags": models.UserTag.objects.filter(book=book),
"lists": privacy_filter(
request.user, book.list_set.filter(listitem__approved=True)
),
"user_tags": user_tags,
"user_shelves": user_shelves,
"other_edition_shelves": other_edition_shelves,
"readthroughs": readthroughs,
"path": "/book/%s" % book_id,
} }
return TemplateResponse(request, "book/book.html", data) return TemplateResponse(request, "book/book.html", data)
@ -266,11 +266,6 @@ class Editions(View):
"""list of editions of a book""" """list of editions of a book"""
work = get_object_or_404(models.Work, id=book_id) work = get_object_or_404(models.Work, id=book_id)
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
if is_api_request(request): if is_api_request(request):
return ActivitypubResponse(work.to_edition_list(**request.GET)) return ActivitypubResponse(work.to_edition_list(**request.GET))
filters = {} filters = {}
@ -280,12 +275,12 @@ class Editions(View):
if request.GET.get("format"): if request.GET.get("format"):
filters["physical_format__iexact"] = request.GET.get("format") filters["physical_format__iexact"] = request.GET.get("format")
editions = work.editions.order_by("-edition_rank").all() editions = work.editions.order_by("-edition_rank")
languages = set(sum([e.languages for e in editions], [])) languages = set(sum([e.languages for e in editions], []))
paginated = Paginator(editions.filter(**filters).all(), PAGE_LENGTH) paginated = Paginator(editions.filter(**filters), PAGE_LENGTH)
data = { data = {
"editions": paginated.get_page(page), "editions": paginated.get_page(request.GET.get("page")),
"work": work, "work": work,
"languages": languages, "languages": languages,
"formats": set( "formats": set(

View file

@ -15,12 +15,6 @@ class Directory(View):
def get(self, request): def get(self, request):
"""lets see your cute faces""" """lets see your cute faces"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
# filters
filters = {} filters = {}
software = request.GET.get("software") software = request.GET.get("software")
if not software or software == "bookwyrm": if not software or software == "bookwyrm":
@ -39,7 +33,7 @@ class Directory(View):
paginated = Paginator(users, 12) paginated = Paginator(users, 12)
data = { data = {
"users": paginated.get_page(page), "users": paginated.get_page(request.GET.get("page")),
} }
return TemplateResponse(request, "directory/directory.html", data) return TemplateResponse(request, "directory/directory.html", data)

View file

@ -24,11 +24,6 @@ class Federation(View):
def get(self, request): def get(self, request):
"""list of servers""" """list of servers"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
servers = models.FederatedServer.objects servers = models.FederatedServer.objects
sort = request.GET.get("sort") sort = request.GET.get("sort")
@ -40,7 +35,7 @@ class Federation(View):
paginated = Paginator(servers, PAGE_LENGTH) paginated = Paginator(servers, PAGE_LENGTH)
data = { data = {
"servers": paginated.get_page(page), "servers": paginated.get_page(request.GET.get("page")),
"sort": sort, "sort": sort,
"form": forms.ServerForm(), "form": forms.ServerForm(),
} }

View file

@ -22,11 +22,6 @@ class Feed(View):
def get(self, request, tab): def get(self, request, tab):
"""user's homepage with activity feed""" """user's homepage with activity feed"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
if not tab in STREAMS: if not tab in STREAMS:
tab = "home" tab = "home"
@ -39,7 +34,7 @@ class Feed(View):
**feed_page_data(request.user), **feed_page_data(request.user),
**{ **{
"user": request.user, "user": request.user,
"activities": paginated.get_page(page), "activities": paginated.get_page(request.GET.get("page")),
"suggested_users": suggested_users, "suggested_users": suggested_users,
"tab": tab, "tab": tab,
"goal_form": forms.GoalForm(), "goal_form": forms.GoalForm(),
@ -55,11 +50,6 @@ class DirectMessage(View):
def get(self, request, username=None): def get(self, request, username=None):
"""like a feed but for dms only""" """like a feed but for dms only"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
# remove fancy subclasses of status, keep just good ol' notes # remove fancy subclasses of status, keep just good ol' notes
queryset = models.Status.objects.filter( queryset = models.Status.objects.filter(
review__isnull=True, review__isnull=True,
@ -82,13 +72,12 @@ class DirectMessage(View):
).order_by("-published_date") ).order_by("-published_date")
paginated = Paginator(activities, PAGE_LENGTH) paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.get_page(page)
data = { data = {
**feed_page_data(request.user), **feed_page_data(request.user),
**{ **{
"user": request.user, "user": request.user,
"partner": user, "partner": user,
"activities": activity_page, "activities": paginated.get_page(request.GET.get("page")),
"path": "/direct-messages", "path": "/direct-messages",
}, },
} }
@ -174,7 +163,7 @@ def get_suggested_books(user, max_books=5):
) )
shelf = user.shelf_set.get(identifier=preset) shelf = user.shelf_set.get(identifier=preset)
shelf_books = shelf.shelfbook_set.order_by("-updated_date").all()[:limit] shelf_books = shelf.shelfbook_set.order_by("-updated_date")[:limit]
if not shelf_books: if not shelf_books:
continue continue
shelf_preview = { shelf_preview = {

View file

@ -30,11 +30,6 @@ class ManageInvites(View):
def get(self, request): def get(self, request):
"""invite management page""" """invite management page"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
paginated = Paginator( paginated = Paginator(
models.SiteInvite.objects.filter(user=request.user).order_by( models.SiteInvite.objects.filter(user=request.user).order_by(
"-created_date" "-created_date"
@ -43,7 +38,7 @@ class ManageInvites(View):
) )
data = { data = {
"invites": paginated.get_page(page), "invites": paginated.get_page(request.GET.get("page")),
"form": forms.CreateInviteForm(), "form": forms.CreateInviteForm(),
} }
return TemplateResponse(request, "settings/manage_invites.html", data) return TemplateResponse(request, "settings/manage_invites.html", data)
@ -93,11 +88,6 @@ class ManageInviteRequests(View):
def get(self, request): def get(self, request):
"""view a list of requests""" """view a list of requests"""
ignored = request.GET.get("ignored", False) ignored = request.GET.get("ignored", False)
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
sort = request.GET.get("sort") sort = request.GET.get("sort")
sort_fields = [ sort_fields = [
"created_date", "created_date",
@ -136,7 +126,7 @@ class ManageInviteRequests(View):
data = { data = {
"ignored": ignored, "ignored": ignored,
"count": paginated.count, "count": paginated.count,
"requests": paginated.get_page(page), "requests": paginated.get_page(request.GET.get("page")),
"sort": sort, "sort": sort,
} }
return TemplateResponse(request, "settings/manage_invite_requests.html", data) return TemplateResponse(request, "settings/manage_invite_requests.html", data)

View file

@ -1,9 +1,12 @@
""" book list views""" """ book list views"""
from typing import Optional
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db import IntegrityError from django.db import IntegrityError, transaction
from django.db.models import Count, Q from django.db.models import Avg, Count, Q, Max
from django.http import HttpResponseNotFound, HttpResponseBadRequest from django.db.models.functions import Coalesce
from django.http import HttpResponseNotFound, HttpResponseBadRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@ -16,17 +19,13 @@ from bookwyrm.connectors import connector_manager
from .helpers import is_api_request, privacy_filter from .helpers import is_api_request, privacy_filter
from .helpers import get_user_from_username from .helpers import get_user_from_username
# pylint: disable=no-self-use # pylint: disable=no-self-use
class Lists(View): class Lists(View):
"""book list page""" """book list page"""
def get(self, request): def get(self, request):
"""display a book list""" """display a book list"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
# hide lists with no approved books # hide lists with no approved books
lists = ( lists = (
models.List.objects.annotate( models.List.objects.annotate(
@ -35,7 +34,6 @@ class Lists(View):
.filter(item_count__gt=0) .filter(item_count__gt=0)
.order_by("-updated_date") .order_by("-updated_date")
.distinct() .distinct()
.all()
) )
lists = privacy_filter( lists = privacy_filter(
@ -44,7 +42,7 @@ class Lists(View):
paginated = Paginator(lists, 12) paginated = Paginator(lists, 12)
data = { data = {
"lists": paginated.get_page(page), "lists": paginated.get_page(request.GET.get("page")),
"list_form": forms.ListForm(), "list_form": forms.ListForm(),
"path": "/list", "path": "/list",
} }
@ -67,19 +65,15 @@ class UserLists(View):
def get(self, request, username): def get(self, request, username):
"""display a book list""" """display a book list"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
user = get_user_from_username(request.user, username) user = get_user_from_username(request.user, username)
lists = models.List.objects.filter(user=user).all() lists = models.List.objects.filter(user=user)
lists = privacy_filter(request.user, lists) lists = privacy_filter(request.user, lists)
paginated = Paginator(lists, 12) paginated = Paginator(lists, 12)
data = { data = {
"user": user, "user": user,
"is_self": request.user.id == user.id, "is_self": request.user.id == user.id,
"lists": paginated.get_page(page), "lists": paginated.get_page(request.GET.get("page")),
"list_form": forms.ListForm(), "list_form": forms.ListForm(),
"path": user.local_path + "/lists", "path": user.local_path + "/lists",
} }
@ -100,6 +94,45 @@ class List(View):
query = request.GET.get("q") query = request.GET.get("q")
suggestions = None suggestions = None
# sort_by shall be "order" unless a valid alternative is given
sort_by = request.GET.get("sort_by", "order")
if sort_by not in ("order", "title", "rating"):
sort_by = "order"
# direction shall be "ascending" unless a valid alternative is given
direction = request.GET.get("direction", "ascending")
if direction not in ("ascending", "descending"):
direction = "ascending"
internal_sort_by = {
"order": "order",
"title": "book__title",
"rating": "average_rating",
}
directional_sort_by = internal_sort_by[sort_by]
if direction == "descending":
directional_sort_by = "-" + directional_sort_by
if sort_by == "order":
items = book_list.listitem_set.filter(approved=True).order_by(
directional_sort_by
)
elif sort_by == "title":
items = book_list.listitem_set.filter(approved=True).order_by(
directional_sort_by
)
elif sort_by == "rating":
items = (
book_list.listitem_set.annotate(
average_rating=Avg(Coalesce("book__review__rating", 0))
)
.filter(approved=True)
.order_by(directional_sort_by)
)
paginated = Paginator(items, 12)
if query and request.user.is_authenticated: if query and request.user.is_authenticated:
# search for books # search for books
suggestions = connector_manager.local_search(query, raw=True) suggestions = connector_manager.local_search(query, raw=True)
@ -119,11 +152,14 @@ class List(View):
data = { data = {
"list": book_list, "list": book_list,
"items": book_list.listitem_set.filter(approved=True), "items": paginated.get_page(request.GET.get("page")),
"pending_count": book_list.listitem_set.filter(approved=False).count(), "pending_count": book_list.listitem_set.filter(approved=False).count(),
"suggested_books": suggestions, "suggested_books": suggestions,
"list_form": forms.ListForm(instance=book_list), "list_form": forms.ListForm(instance=book_list),
"query": query or "", "query": query or "",
"sort_form": forms.SortListForm(
{"direction": direction, "sort_by": sort_by}
),
} }
return TemplateResponse(request, "lists/list.html", data) return TemplateResponse(request, "lists/list.html", data)
@ -165,10 +201,22 @@ class Curate(View):
suggestion = get_object_or_404(models.ListItem, id=request.POST.get("item")) suggestion = get_object_or_404(models.ListItem, id=request.POST.get("item"))
approved = request.POST.get("approved") == "true" approved = request.POST.get("approved") == "true"
if approved: if approved:
# update the book and set it to be the last in the order of approved books,
# before any pending books
suggestion.approved = True suggestion.approved = True
order_max = (
book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[
"order__max"
]
or 0
) + 1
suggestion.order = order_max
increment_order_in_reverse(book_list.id, order_max)
suggestion.save() suggestion.save()
else: else:
deleted_order = suggestion.order
suggestion.delete(broadcast=False) suggestion.delete(broadcast=False)
normalize_book_list_ordering(book_list.id, start=deleted_order)
return redirect("list-curate", book_list.id) return redirect("list-curate", book_list.id)
@ -183,19 +231,30 @@ def add_book(request):
# do you have permission to add to the list? # do you have permission to add to the list?
try: try:
if request.user == book_list.user or book_list.curation == "open": if request.user == book_list.user or book_list.curation == "open":
# go ahead and add it # add the book at the latest order of approved books, before pending books
order_max = (
book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[
"order__max"
]
) or 0
increment_order_in_reverse(book_list.id, order_max + 1)
models.ListItem.objects.create( models.ListItem.objects.create(
book=book, book=book,
book_list=book_list, book_list=book_list,
user=request.user, user=request.user,
order=order_max + 1,
) )
elif book_list.curation == "curated": elif book_list.curation == "curated":
# make a pending entry # make a pending entry at the end of the list
order_max = (
book_list.listitem_set.aggregate(Max("order"))["order__max"]
) or 0
models.ListItem.objects.create( models.ListItem.objects.create(
approved=False, approved=False,
book=book, book=book,
book_list=book_list, book_list=book_list,
user=request.user, user=request.user,
order=order_max + 1,
) )
else: else:
# you can't add to this list, what were you THINKING # you can't add to this list, what were you THINKING
@ -209,12 +268,113 @@ def add_book(request):
@require_POST @require_POST
def remove_book(request, list_id): def remove_book(request, list_id):
""" put a book on a list """ """remove a book from a list"""
with transaction.atomic():
book_list = get_object_or_404(models.List, id=list_id) book_list = get_object_or_404(models.List, id=list_id)
item = get_object_or_404(models.ListItem, id=request.POST.get("item")) item = get_object_or_404(models.ListItem, id=request.POST.get("item"))
if not book_list.user == request.user and not item.user == request.user: if not book_list.user == request.user and not item.user == request.user:
return HttpResponseNotFound() return HttpResponseNotFound()
deleted_order = item.order
item.delete() item.delete()
normalize_book_list_ordering(book_list.id, start=deleted_order)
return redirect("list", list_id) return redirect("list", list_id)
@require_POST
def set_book_position(request, list_item_id):
"""
Action for when the list user manually specifies a list position, takes
special care with the unique ordering per list.
"""
with transaction.atomic():
list_item = get_object_or_404(models.ListItem, id=list_item_id)
try:
int_position = int(request.POST.get("position"))
except ValueError:
return HttpResponseBadRequest(
"bad value for position. should be an integer"
)
if int_position < 1:
return HttpResponseBadRequest("position cannot be less than 1")
book_list = list_item.book_list
# the max position to which a book may be set is the highest order for
# books which are approved
order_max = book_list.listitem_set.filter(approved=True).aggregate(
Max("order")
)["order__max"]
if int_position > order_max:
int_position = order_max
if request.user not in (book_list.user, list_item.user):
return HttpResponseNotFound()
original_order = list_item.order
if original_order == int_position:
return HttpResponse(status=204)
if original_order > int_position:
list_item.order = -1
list_item.save()
increment_order_in_reverse(book_list.id, int_position, original_order)
else:
list_item.order = -1
list_item.save()
decrement_order(book_list.id, original_order, int_position)
list_item.order = int_position
list_item.save()
return redirect("list", book_list.id)
@transaction.atomic
def increment_order_in_reverse(
book_list_id: int, start: int, end: Optional[int] = None
):
"""increase the order nu,ber for every item in a list"""
try:
book_list = models.List.objects.get(id=book_list_id)
except models.List.DoesNotExist:
return
items = book_list.listitem_set.filter(order__gte=start)
if end is not None:
items = items.filter(order__lt=end)
items = items.order_by("-order")
for item in items:
item.order += 1
item.save()
@transaction.atomic
def decrement_order(book_list_id, start, end):
"""decrement the order value for every item in a list"""
try:
book_list = models.List.objects.get(id=book_list_id)
except models.List.DoesNotExist:
return
items = book_list.listitem_set.filter(order__gt=start, order__lte=end).order_by(
"order"
)
for item in items:
item.order -= 1
item.save()
@transaction.atomic
def normalize_book_list_ordering(book_list_id, start=0, add_offset=0):
"""gives each book in a list the proper sequential order number"""
try:
book_list = models.List.objects.get(id=book_list_id)
except models.List.DoesNotExist:
return
items = book_list.listitem_set.filter(order__gt=start).order_by("order")
for i, item in enumerate(items, start):
effective_order = i + add_offset
if item.order != effective_order:
item.order = effective_order
item.save()

View file

@ -145,6 +145,7 @@ def create_readthrough(request):
def load_date_in_user_tz_as_utc(date_str: str, user: models.User) -> datetime: def load_date_in_user_tz_as_utc(date_str: str, user: models.User) -> datetime:
"""ensures that data is stored consistently in the UTC timezone"""
user_tz = dateutil.tz.gettz(user.preferred_timezone) user_tz = dateutil.tz.gettz(user.preferred_timezone)
start_date = dateutil.parser.parse(date_str, ignoretz=True) start_date = dateutil.parser.parse(date_str, ignoretz=True)
return start_date.replace(tzinfo=user_tz).astimezone(dateutil.tz.UTC) return start_date.replace(tzinfo=user_tz).astimezone(dateutil.tz.UTC)

View file

@ -74,12 +74,13 @@ class Report(View):
@login_required @login_required
@permission_required("bookwyrm_moderate_user") @permission_required("bookwyrm_moderate_user")
def deactivate_user(_, report_id): def suspend_user(_, user_id):
"""mark an account as inactive""" """mark an account as inactive"""
report = get_object_or_404(models.Report, id=report_id) user = get_object_or_404(models.User, id=user_id)
report.user.is_active = not report.user.is_active user.is_active = not user.is_active
report.user.save() # this isn't a full deletion, so we don't want to tell the world
return redirect("settings-report", report.id) user.save(broadcast=False)
return redirect("settings-user", user.id)
@login_required @login_required

View file

@ -30,11 +30,6 @@ class Shelf(View):
except models.User.DoesNotExist: except models.User.DoesNotExist:
return HttpResponseNotFound() return HttpResponseNotFound()
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
shelves = privacy_filter(request.user, user.shelf_set) shelves = privacy_filter(request.user, user.shelf_set)
# get the shelf and make sure the logged in user should be able to see it # get the shelf and make sure the logged in user should be able to see it
@ -61,7 +56,7 @@ class Shelf(View):
return ActivitypubResponse(shelf.to_activity(**request.GET)) return ActivitypubResponse(shelf.to_activity(**request.GET))
paginated = Paginator( paginated = Paginator(
shelf.books.order_by("-updated_date").all(), shelf.books.order_by("-updated_date"),
PAGE_LENGTH, PAGE_LENGTH,
) )
@ -70,7 +65,7 @@ class Shelf(View):
"is_self": is_self, "is_self": is_self,
"shelves": shelves.all(), "shelves": shelves.all(),
"shelf": shelf, "shelf": shelf,
"books": paginated.get_page(page), "books": paginated.get_page(request.GET.get("page")),
} }
return TemplateResponse(request, "user/shelf.html", data) return TemplateResponse(request, "user/shelf.html", data)

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)

View file

@ -40,11 +40,6 @@ class User(View):
return ActivitypubResponse(user.to_activity()) return ActivitypubResponse(user.to_activity())
# otherwise we're at a UI view # otherwise we're at a UI view
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
shelf_preview = [] shelf_preview = []
# only show other shelves that should be visible # only show other shelves that should be visible
@ -87,7 +82,7 @@ class User(View):
"is_self": is_self, "is_self": is_self,
"shelves": shelf_preview, "shelves": shelf_preview,
"shelf_count": shelves.count(), "shelf_count": shelves.count(),
"activities": paginated.get_page(page), "activities": paginated.get_page(request.GET.get("page", 1)),
"goal": goal, "goal": goal,
} }

View file

@ -1,11 +1,12 @@
""" manage user """ """ manage user """
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.shortcuts import get_object_or_404
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
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 bookwyrm import models from bookwyrm import forms, models
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
@ -15,16 +16,11 @@ from bookwyrm.settings import PAGE_LENGTH
permission_required("bookwyrm.moderate_users", raise_exception=True), permission_required("bookwyrm.moderate_users", raise_exception=True),
name="dispatch", name="dispatch",
) )
class UserAdmin(View): class UserAdminList(View):
"""admin view of users on this server""" """admin view of users on this server"""
def get(self, request): def get(self, request):
"""list of users""" """list of users"""
try:
page = int(request.GET.get("page", 1))
except ValueError:
page = 1
filters = {} filters = {}
server = request.GET.get("server") server = request.GET.get("server")
if server: if server:
@ -50,8 +46,32 @@ class UserAdmin(View):
paginated = Paginator(users, PAGE_LENGTH) paginated = Paginator(users, PAGE_LENGTH)
data = { data = {
"users": paginated.get_page(page), "users": paginated.get_page(request.GET.get("page")),
"sort": sort, "sort": sort,
"server": server, "server": server,
} }
return TemplateResponse(request, "settings/user_admin.html", data) return TemplateResponse(request, "user_admin/user_admin.html", data)
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.moderate_users", raise_exception=True),
name="dispatch",
)
class UserAdmin(View):
"""moderate an individual user"""
def get(self, request, user):
"""user view"""
user = get_object_or_404(models.User, id=user)
data = {"user": user, "group_form": forms.UserGroupForm()}
return TemplateResponse(request, "user_admin/user.html", data)
def post(self, request, user):
"""update user group"""
user = get_object_or_404(models.User, id=user)
form = forms.UserGroupForm(request.POST, instance=user)
if form.is_valid():
form.save()
data = {"user": user, "group_form": form}
return TemplateResponse(request, "user_admin/user.html", data)

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

1
fr-dev
View file

@ -1 +0,0 @@
bw-dev

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -15,7 +15,7 @@ django-rename-app==0.1.2
pytz>=2021.1 pytz>=2021.1
# Dev # Dev
black==20.8b1 black==21.4b0
coverage==5.1 coverage==5.1
pytest-django==4.1.0 pytest-django==4.1.0
pytest==6.1.2 pytest==6.1.2