mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2025-04-23 18:54:10 +00:00
Merge b5b9a4fb64
into 4c764cd543
This commit is contained in:
commit
f050b5a221
26 changed files with 1026 additions and 167 deletions
|
@ -15,7 +15,7 @@ from .note import Review, Rating
|
|||
from .note import Tombstone
|
||||
from .ordered_collection import OrderedCollection, OrderedCollectionPage
|
||||
from .ordered_collection import CollectionItem, ListItem, ShelfItem
|
||||
from .ordered_collection import BookList, Shelf
|
||||
from .ordered_collection import BookList, SuggestionList, Shelf
|
||||
from .person import Person, PublicKey
|
||||
from .response import ActivitypubResponse
|
||||
from .book import Edition, Work, Author
|
||||
|
|
|
@ -42,6 +42,15 @@ class BookList(OrderedCollectionPrivate):
|
|||
type: str = "BookList"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class SuggestionList(OrderedCollectionPrivate):
|
||||
"""structure of an ordered collection activity"""
|
||||
|
||||
summary: str = None
|
||||
book: str = None
|
||||
type: str = "SuggestionList"
|
||||
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@dataclass(init=False)
|
||||
class OrderedCollectionPage(ActivityObject):
|
||||
|
|
|
@ -20,6 +20,18 @@ class ListItemForm(CustomForm):
|
|||
fields = ["user", "book", "book_list", "notes"]
|
||||
|
||||
|
||||
class SuggestionListForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SuggestionList
|
||||
fields = ["suggests_for"]
|
||||
|
||||
|
||||
class SuggestionListItemForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SuggestionListItem
|
||||
fields = ["user", "book", "book_list", "notes"]
|
||||
|
||||
|
||||
class SortListForm(forms.Form):
|
||||
sort_by = ChoiceField(
|
||||
choices=(
|
||||
|
|
|
@ -0,0 +1,153 @@
|
|||
# Generated by Django 4.2.15 on 2024-08-27 17:27
|
||||
|
||||
import bookwyrm.models.activitypub_mixin
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0209_user_show_ratings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SuggestionList",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_date", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"remote_id",
|
||||
bookwyrm.models.fields.RemoteIdField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||
),
|
||||
),
|
||||
("embed_key", models.UUIDField(editable=False, null=True, unique=True)),
|
||||
(
|
||||
"privacy",
|
||||
bookwyrm.models.fields.PrivacyField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("unlisted", "Unlisted"),
|
||||
("followers", "Followers"),
|
||||
("direct", "Private"),
|
||||
],
|
||||
default="public",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-updated_date",),
|
||||
"abstract": False,
|
||||
},
|
||||
bases=(
|
||||
bookwyrm.models.activitypub_mixin.OrderedCollectionMixin,
|
||||
models.Model,
|
||||
),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="listitem",
|
||||
options={},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SuggestionListItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("created_date", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_date", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"remote_id",
|
||||
bookwyrm.models.fields.RemoteIdField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||
),
|
||||
),
|
||||
(
|
||||
"notes",
|
||||
bookwyrm.models.fields.HtmlField(
|
||||
blank=True, max_length=300, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"book",
|
||||
bookwyrm.models.fields.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to="bookwyrm.edition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"book_list",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="bookwyrm.suggestionlist",
|
||||
),
|
||||
),
|
||||
(
|
||||
"endorsement",
|
||||
models.ManyToManyField(
|
||||
related_name="suggestion_endorsers", to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
bookwyrm.models.fields.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-created_date",),
|
||||
"abstract": False,
|
||||
"unique_together": {("book", "book_list")},
|
||||
},
|
||||
bases=(bookwyrm.models.activitypub_mixin.CollectionItemMixin, models.Model),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="suggestionlist",
|
||||
name="books",
|
||||
field=models.ManyToManyField(
|
||||
through="bookwyrm.SuggestionListItem", to="bookwyrm.edition"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="suggestionlist",
|
||||
name="suggests_for",
|
||||
field=bookwyrm.models.fields.OneToOneField(
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
related_name="suggestion_list",
|
||||
to="bookwyrm.work",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="suggestionlist",
|
||||
name="user",
|
||||
field=bookwyrm.models.fields.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
]
|
|
@ -9,6 +9,7 @@ from .connector import Connector
|
|||
|
||||
from .shelf import Shelf, ShelfBook
|
||||
from .list import List, ListItem
|
||||
from .list import SuggestionList, SuggestionListItem
|
||||
|
||||
from .status import Status, GeneratedNote, Comment, Quotation
|
||||
from .status import Review, ReviewRating
|
||||
|
|
|
@ -303,7 +303,7 @@ class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
|||
|
||||
|
||||
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
|
||||
"""activitypub-aware foreign key field"""
|
||||
"""activitypub-aware one to one field"""
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if not value:
|
||||
|
@ -625,11 +625,11 @@ class BooleanField(ActivitypubFieldMixin, models.BooleanField):
|
|||
|
||||
|
||||
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
|
||||
"""activitypub-aware boolean field"""
|
||||
"""activitypub-aware integer field"""
|
||||
|
||||
|
||||
class DecimalField(ActivitypubFieldMixin, models.DecimalField):
|
||||
"""activitypub-aware boolean field"""
|
||||
"""activitypub-aware decimal field"""
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if not value:
|
||||
|
|
|
@ -6,6 +6,7 @@ from django.core.exceptions import PermissionDenied
|
|||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import BASE_URL
|
||||
|
@ -22,15 +23,101 @@ CurationType = models.TextChoices(
|
|||
)
|
||||
|
||||
|
||||
class List(OrderedCollectionMixin, BookWyrmModel):
|
||||
"""a list of books"""
|
||||
class AbstractList(OrderedCollectionMixin, BookWyrmModel):
|
||||
"""Abstract model for regular lists and suggestion lists"""
|
||||
|
||||
name = fields.CharField(max_length=100)
|
||||
embed_key = models.UUIDField(unique=True, null=True, editable=False)
|
||||
activity_serializer = activitypub.BookList
|
||||
privacy = fields.PrivacyField()
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="owner"
|
||||
)
|
||||
|
||||
def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
|
||||
"""on save, update embed_key and avoid clash with existing code"""
|
||||
if not self.embed_key:
|
||||
self.embed_key = uuid.uuid4()
|
||||
update_fields = add_update_fields(update_fields, "embed_key")
|
||||
|
||||
super().save(*args, update_fields=update_fields, **kwargs)
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
raise NotImplementedError
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-updated_date",)
|
||||
abstract = True
|
||||
|
||||
|
||||
class SuggestionList(AbstractList):
|
||||
"""a list of user-provided suggested things to read next"""
|
||||
|
||||
books = models.ManyToManyField(
|
||||
"Edition",
|
||||
symmetrical=False,
|
||||
through="SuggestionListItem",
|
||||
through_fields=("book_list", "book"),
|
||||
)
|
||||
|
||||
suggests_for = fields.OneToOneField(
|
||||
"Work",
|
||||
on_delete=models.PROTECT,
|
||||
activitypub_field="book",
|
||||
related_name="suggestion_list",
|
||||
unique=True,
|
||||
)
|
||||
activity_serializer = activitypub.SuggestionList
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.order_by("suggestionlistitem")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""on save, update embed_key and avoid clash with existing code"""
|
||||
self.user = activitypub.get_representative()
|
||||
self.privacy = "public"
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""anyone can create a suggestion list, no one can edit"""
|
||||
return
|
||||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return f"{BASE_URL}/book/{self.suggests_for.id}/suggestions"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""The name comes from the book title if it's a suggestion list"""
|
||||
return _("Suggestions for %(title)s") % {"title": self.suggests_for.title}
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
"""The description comes from the book title if it's a suggestion list"""
|
||||
return _(
|
||||
"This is the list of suggestions for <a href='%(url)s'>%(title)s</a>"
|
||||
) % {
|
||||
"title": self.suggests_for.title,
|
||||
"url": self.suggests_for.local_path,
|
||||
}
|
||||
|
||||
|
||||
class List(AbstractList):
|
||||
"""a list of books"""
|
||||
|
||||
books = models.ManyToManyField(
|
||||
"Edition",
|
||||
symmetrical=False,
|
||||
through="ListItem",
|
||||
through_fields=("book_list", "book"),
|
||||
)
|
||||
name = fields.CharField(max_length=100)
|
||||
description = fields.TextField(blank=True, null=True, activitypub_field="summary")
|
||||
privacy = fields.PrivacyField()
|
||||
curation = fields.CharField(
|
||||
max_length=255, default="closed", choices=CurationType.choices
|
||||
)
|
||||
|
@ -41,28 +128,15 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
books = models.ManyToManyField(
|
||||
"Edition",
|
||||
symmetrical=False,
|
||||
through="ListItem",
|
||||
through_fields=("book_list", "book"),
|
||||
)
|
||||
embed_key = models.UUIDField(unique=True, null=True, editable=False)
|
||||
activity_serializer = activitypub.BookList
|
||||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return f"{BASE_URL}/list/{self.id}"
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.filter(listitem__approved=True).order_by("listitem")
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-updated_date",)
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return f"{BASE_URL}/list/{self.id}"
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""the associated user OR the list owner can edit"""
|
||||
|
@ -126,44 +200,59 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
group=None, curation="closed"
|
||||
)
|
||||
|
||||
def save(self, *args, update_fields: Optional[Iterable[str]] = None, **kwargs):
|
||||
"""on save, update embed_key and avoid clash with existing code"""
|
||||
if not self.embed_key:
|
||||
self.embed_key = uuid.uuid4()
|
||||
update_fields = add_update_fields(update_fields, "embed_key")
|
||||
|
||||
super().save(*args, update_fields=update_fields, **kwargs)
|
||||
|
||||
|
||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
"""ok"""
|
||||
class AbstractListItem(CollectionItemMixin, BookWyrmModel):
|
||||
"""Abstracy class for list items for all types of lists"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||
)
|
||||
book_list = models.ForeignKey("List", on_delete=models.CASCADE)
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
notes = fields.HtmlField(blank=True, null=True, max_length=300)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField()
|
||||
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
||||
|
||||
activity_serializer = activitypub.ListItem
|
||||
collection_field = "book_list"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Update the list's date"""
|
||||
super().save(*args, **kwargs)
|
||||
# tick the updated date on the parent list
|
||||
self.book_list.updated_date = timezone.now()
|
||||
self.book_list.save(broadcast=False, update_fields=["updated_date"])
|
||||
def endorse(self, user):
|
||||
"""another user supports this suggestion"""
|
||||
# you can't endorse your own contribution, silly
|
||||
if user == self.user:
|
||||
return
|
||||
self.endorsement.add(user)
|
||||
|
||||
def unendorse(self, user):
|
||||
"""the user rescinds support this suggestion"""
|
||||
if user == self.user:
|
||||
return
|
||||
self.endorsement.remove(user)
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""the associated user OR the list owner can delete"""
|
||||
if self.book_list.user == viewer:
|
||||
return
|
||||
super().raise_not_deletable(viewer)
|
||||
|
||||
class Meta:
|
||||
"""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")
|
||||
ordering = ("-created_date",)
|
||||
abstract = True
|
||||
|
||||
|
||||
class ListItem(AbstractListItem):
|
||||
"""ok"""
|
||||
|
||||
book_list = models.ForeignKey("List", on_delete=models.CASCADE)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField()
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""the associated user OR the list owner can delete"""
|
||||
# group members can delete items in group lists
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.book_list.group, user=viewer
|
||||
|
@ -172,9 +261,22 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
return
|
||||
super().raise_not_deletable(viewer)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Update the list's date"""
|
||||
super().save(*args, **kwargs)
|
||||
# tick the updated date on the parent list
|
||||
self.book_list.updated_date = timezone.now()
|
||||
self.book_list.save(broadcast=False, update_fields=["updated_date"])
|
||||
|
||||
class Meta:
|
||||
"""A book may only be placed into a list once,
|
||||
and each order in the list may be used only once"""
|
||||
|
||||
unique_together = (("book", "book_list"), ("order", "book_list"))
|
||||
ordering = ("-created_date",)
|
||||
|
||||
|
||||
class SuggestionListItem(AbstractListItem):
|
||||
"""items on a suggestion list"""
|
||||
|
||||
book_list = models.ForeignKey("SuggestionList", on_delete=models.CASCADE)
|
||||
endorsement = models.ManyToManyField("User", related_name="suggestion_endorsers")
|
||||
|
|
|
@ -383,7 +383,7 @@
|
|||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if lists.exists or request.user.list_set.exists %}
|
||||
{% if lists.exists or list_options.exists %}
|
||||
<section class="content block is-clipped">
|
||||
<h2 class="title is-5">{% trans "Lists" %}</h2>
|
||||
<ul>
|
||||
|
@ -420,8 +420,11 @@
|
|||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<section class="block">
|
||||
{% include "book/suggestion_list/list.html" with list=suggstion_list %}
|
||||
</section>
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
|
|
43
bookwyrm/templates/book/suggestion_list/book_card.html
Normal file
43
bookwyrm/templates/book/suggestion_list/book_card.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{% load i18n %}
|
||||
{% load book_display_tags %}
|
||||
|
||||
<div class="card is-stretchable">
|
||||
<div class="card-content pb-0 columns">
|
||||
{% with item_book=item.book %}
|
||||
<div class="column is-one-third">
|
||||
<div class="is-cover">
|
||||
<a href="{{ item_book.local_path }}" aria-hidden="true">
|
||||
{% include 'snippets/book_cover.html' with cover_class='is-h-m-mobile is-h-m-tablet is-align-items-flex-start' size='medium' book=item_book %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-two-thirds">
|
||||
<p>
|
||||
{% include 'snippets/book_titleby.html' with book=item_book %}
|
||||
</p>
|
||||
{% if item.notes %}
|
||||
{% include "lists/list_item_notes.html" with list=book.suggestion_list hide_edit=True no_trim=False trim_length=15 %}
|
||||
{% else %}
|
||||
<blockquote>
|
||||
{% with full=item_book|book_description %}
|
||||
{% include 'snippets/trimmed_text.html' with trim_length=15 hide_more=True %}
|
||||
{% endwith %}
|
||||
</blockquote>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="card-footer is-stacked-mobile has-background-tertiary is-align-items-stretch">
|
||||
<div class="card-footer-item p-0">
|
||||
<p>
|
||||
{% blocktrans trimmed with username=item.user.display_name user_path=item.user.local_path %}
|
||||
Added by <a href="{{ user_path }}">{{ username }}</a>
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
{% include "book/suggestion_list/endorsement_button.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{% load i18n %}
|
||||
<form class="" action="{% url "suggestion-endorse" work.id item.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<button class="tag is-info {% if request.user not in item.endorsement.all and request.user != item.user %}is-light{% endif %}" type="submit" {% if request.user == item.user %}disabled{% endif %}>
|
||||
<span class="icon icon-star-empty" aria-label="{% trans 'Endorsements' %}"></span>
|
||||
<span>
|
||||
{{ item.endorsement.count|add:1 }}
|
||||
</span>
|
||||
</button>
|
||||
</form>
|
46
bookwyrm/templates/book/suggestion_list/list.html
Normal file
46
bookwyrm/templates/book/suggestion_list/list.html
Normal file
|
@ -0,0 +1,46 @@
|
|||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
<h2 class="title is-3" id="suggestions-section">
|
||||
{% trans "Suggestions" %}
|
||||
<a class="help has-text-weight-normal" href="{% url 'suggestion-list' book_id=work.id %}">
|
||||
{% trans "View all suggestions" %}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
{% if suggestion_list %}
|
||||
|
||||
<p class="subtitle">
|
||||
{% blocktrans trimmed with title=book.title %}
|
||||
Readers who liked <em>{{ title }}</em> recommend giving these books a try:
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if items|length == 0 %}
|
||||
<section class="section has-background-light is-flex is-flex-direction-column is-align-items-center">
|
||||
<div class="column is-full is-centered">
|
||||
{% include "book/suggestion_list/search.html" with list=suggestion_list is_suggestion=True %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<ol class="columns is-multiline">
|
||||
{% for item in items %}
|
||||
<li class="mb-5 column is-one-third">
|
||||
{% include "book/suggestion_list/book_card.html" with list=suggestion_list %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
<div class="mb-5 column is-full">
|
||||
{% include "book/suggestion_list/search.html" with list=suggestion_list is_suggestion=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<section class="section is-medium has-background-light">
|
||||
<form name="create-list" method="post" action="{% url 'suggestion-list' book_id=book.id %}#suggestions-section" class="has-text-centered">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
<input type="hidden" name="suggests_for" value="{{ work.id }}">
|
||||
<button type="submit" class="button is-medium">{% trans "Create suggestion list" %}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
17
bookwyrm/templates/book/suggestion_list/search.html
Normal file
17
bookwyrm/templates/book/suggestion_list/search.html
Normal file
|
@ -0,0 +1,17 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
<details class="details-panel box" {% if query %}open{% endif %} id="add-suggestions">
|
||||
<summary>
|
||||
<span class="title is-5" role="heading" aria-level="3">
|
||||
{% trans "Add suggestions" %}
|
||||
</span>
|
||||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||
</summary>
|
||||
{% with search_url=request.path|add:"#add-suggestions" %}
|
||||
{% include "lists/suggestion_search.html" with is_suggestion=True query_param="suggestion_query" columns=True %}
|
||||
{% endwith %}
|
||||
</details>
|
||||
{% endif %}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
{% load group_tags %}
|
||||
|
||||
{% block modal-title %}
|
||||
{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% if is_suggestion or list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% blocktrans trimmed with title=book|book_title %}
|
||||
Add "<em>{{ title }}</em>" to this list
|
||||
{% endblocktrans %}
|
||||
|
@ -19,7 +19,11 @@
|
|||
<form
|
||||
name="add-book-{{ book.id }}"
|
||||
method="POST"
|
||||
action="{% url 'list-add-book' %}{% if query %}?q={{ query }}{% endif %}"
|
||||
{% if is_suggestion %}
|
||||
action="{% url 'book-add-suggestion' book_id=list.suggests_for.id %}#suggestions-section"
|
||||
{% else %}
|
||||
action="{% url 'list-add-book' %}"
|
||||
{% endif %}
|
||||
>
|
||||
{% endblock %}
|
||||
|
||||
|
@ -35,7 +39,7 @@
|
|||
<div class="buttons is-right is-flex-grow-1">
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
<button type="submit" class="button is-link">
|
||||
{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% if is_suggestion or list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% trans "Add" %}
|
||||
{% else %}
|
||||
{% trans "Suggest" %}
|
||||
|
|
|
@ -12,13 +12,15 @@
|
|||
<header class="columns content is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1>
|
||||
{% if list.suggests_for == None %}
|
||||
<p class="subtitle help">
|
||||
{% include 'lists/created_text.html' with list=list %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="column is-narrow is-flex field is-grouped">
|
||||
{% if request.user == list.user %}
|
||||
{% if request.user == list.user and list.suggests_for == None %}
|
||||
<div class="control">
|
||||
{% trans "Edit List" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_list" focus="edit_list_header" %}
|
||||
|
|
|
@ -80,50 +80,8 @@
|
|||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% include "lists/list_item_notes.html" with item=item no_trim=True %}
|
||||
|
||||
{% if item.notes %}
|
||||
<div class="media notification">
|
||||
<figure class="media-left" aria-hidden="true">
|
||||
{% include "snippets/avatar.html" with user=item.user %}
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<header>
|
||||
{% url 'user-feed' item.user|username as user_path %}
|
||||
{% blocktrans trimmed with username=item.user.display_name %}
|
||||
<a href="{{ user_path }}">{{ username }}</a> says:
|
||||
{% endblocktrans %}
|
||||
</header>
|
||||
{{ item.notes|to_markdown|safe }}
|
||||
</div>
|
||||
{% if item.user == request.user %}
|
||||
<div>
|
||||
<details class="details-panel box">
|
||||
<summary>
|
||||
<span role="heading" aria-level="3">
|
||||
{% trans "Edit notes" %}
|
||||
<span class="details-close icon icon-pencil" aria-hidden="true"></span>
|
||||
</span>
|
||||
</summary>
|
||||
{% include "lists/edit_item_form.html" with book=item.book %}
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif item.user == request.user %}
|
||||
<div>
|
||||
<details class="details-panel box">
|
||||
<summary>
|
||||
<span role="heading" aria-level="3">
|
||||
{% trans "Add notes" %}
|
||||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||
</span>
|
||||
</summary>
|
||||
{% include "lists/edit_item_form.html" with book=item.book %}
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-footer is-stacked-mobile has-background-tertiary is-align-items-stretch">
|
||||
<div class="card-footer-item">
|
||||
|
@ -133,6 +91,11 @@
|
|||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
{% if list.suggests_for %}
|
||||
<div class="card-footer-item">
|
||||
{% include "book/suggestion_list/endorsement_button.html" with work=list.suggests_for %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if list.user == request.user or list.group|is_member:request.user %}
|
||||
<form
|
||||
name="set-position-{{ item.id }}"
|
||||
|
@ -155,11 +118,11 @@
|
|||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if list.user == request.user or list.curation == 'open' and item.user == request.user or list.group|is_member:request.user %}
|
||||
{% if item.user == request.user or list.curation == 'open' and item.user == request.user or list.group|is_member:request.user %}
|
||||
<form
|
||||
name="remove-book-{{ item.id }}"
|
||||
method="post"
|
||||
action="{% url 'list-remove-book' list.id %}"
|
||||
action="{{ remove_book_url }}"
|
||||
class="card-footer-item"
|
||||
>
|
||||
{% csrf_token %}
|
||||
|
@ -177,6 +140,7 @@
|
|||
</section>
|
||||
|
||||
<section class="column is-one-quarter">
|
||||
{% if list.suggests_for == None %}
|
||||
<h2 class="title is-5">
|
||||
{% trans "Sort List" %}
|
||||
</h2>
|
||||
|
@ -199,69 +163,16 @@
|
|||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
|
||||
<h2 class="title is-5 mt-6">
|
||||
{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% if list.suggests_for or list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% trans "Add Books" %}
|
||||
{% else %}
|
||||
{% trans "Suggest Books" %}
|
||||
{% endif %}
|
||||
</h2>
|
||||
<form name="search" action="{% url 'list' list_id=list.id slug=list.name|slugify %}" method="GET" class="block">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input aria-label="{% trans 'Search for a book' %}" class="input" type="text" name="q" placeholder="{% trans 'Search for a book' %}" value="{{ query }}" spellcheck="false">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||
<span class="is-sr-only">{% trans "search" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if query %}
|
||||
<p class="help"><a href="{% url 'list' list_id=list.id slug=list.name|slugify %}">{% trans "Clear search" %}</a></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if not suggested_books %}
|
||||
{% if query %}
|
||||
<p>{% blocktrans %}No books found matching the query "{{ query }}"{% endblocktrans %}</p>{% else %}
|
||||
<p>{% trans "No books found" %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if suggested_books|length > 0 %}
|
||||
{% for book in suggested_books %}
|
||||
<div class="columns is-mobile is-gapless">
|
||||
<a
|
||||
class="column is-2-mobile is-3-tablet is-cover align to-c"
|
||||
href="{{ book.local_path }}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto is-h-s-mobile align to-t' size='small' %}
|
||||
</a>
|
||||
|
||||
<div class="column ml-3">
|
||||
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
||||
|
||||
{% join "add_item" list.id book.id as modal_id %}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-link"
|
||||
data-modal-open="{{ modal_id }}"
|
||||
>
|
||||
{% if list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% trans "Add" %}
|
||||
{% else %}
|
||||
{% trans "Suggest" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
{% include "lists/add_item_modal.html" with id=modal_id %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% include "lists/suggestion_search.html" with query_param="q" search_url=request.path %}
|
||||
{% endif %}
|
||||
<div>
|
||||
<h2 class="title is-5 mt-6" id="embed-label">
|
||||
|
|
49
bookwyrm/templates/lists/list_item_notes.html
Normal file
49
bookwyrm/templates/lists/list_item_notes.html
Normal file
|
@ -0,0 +1,49 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
|
||||
{% if item.notes %}
|
||||
<div class="notification">
|
||||
<div class="is-flex mb-3">
|
||||
<figure class="mr-3" aria-hidden="true">
|
||||
{% include "snippets/avatar.html" with user=item.user %}
|
||||
</figure>
|
||||
<header>
|
||||
{% url 'user-feed' item.user|username as user_path %}
|
||||
{% blocktrans trimmed with username=item.user.display_name %}
|
||||
<a href="{{ user_path }}">{{ username }}</a> says:
|
||||
{% endblocktrans %}
|
||||
</header>
|
||||
</div>
|
||||
<div class="block">
|
||||
|
||||
{% include 'snippets/trimmed_text.html' with no_trim=no_trim full=item.notes %}
|
||||
|
||||
{% if item.user == request.user and not hide_edit %}
|
||||
<div>
|
||||
<details class="details-panel box">
|
||||
<summary>
|
||||
<span role="heading" aria-level="3">
|
||||
{% trans "Edit notes" %}
|
||||
<span class="details-close icon icon-pencil" aria-hidden="true"></span>
|
||||
</span>
|
||||
</summary>
|
||||
{% include "lists/edit_item_form.html" with book=item.book %}
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif item.user == request.user and not hide_edit %}
|
||||
<div>
|
||||
<details class="details-panel box">
|
||||
<summary>
|
||||
<span role="heading" aria-level="3">
|
||||
{% trans "Add notes" %}
|
||||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||
</span>
|
||||
</summary>
|
||||
{% include "lists/edit_item_form.html" with book=item.book %}
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
64
bookwyrm/templates/lists/suggestion_search.html
Normal file
64
bookwyrm/templates/lists/suggestion_search.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
{% load i18n %}
|
||||
{% load utilities %}
|
||||
{% load group_tags %}
|
||||
|
||||
<form name="search" action="{{ search_url }}" method="GET" class="block">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input aria-label="{% trans 'Search for a book' %}" class="input" type="text" name="{{ query_param }}" placeholder="{% trans 'Search for a book' %}" value="{{ query }}" spellcheck="false">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button" type="submit">
|
||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||
<span class="is-sr-only">{% trans "search" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if query %}
|
||||
<p class="help"><a href="{{ search_url }}">{% trans "Clear search" %}</a></p>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% if not suggested_books %}
|
||||
{% if query %}
|
||||
<p>{% blocktrans %}No books found matching the query "{{ query }}"{% endblocktrans %}</p>{% else %}
|
||||
<p>{% trans "No books found" %}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if suggested_books|length > 0 %}
|
||||
<div{% if columns %} class="columns is-multiline"{% endif %}>
|
||||
{% for book in suggested_books %}
|
||||
<div class="block {% if columns %}column is-5 m-0{% endif %}">
|
||||
<div class="columns is-mobile is-gapless">
|
||||
<a
|
||||
class="column is-2-mobile is-3-tablet is-cover align to-c"
|
||||
href="{{ book.local_path }}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{% include 'snippets/book_cover.html' with book=book cover_class='is-w-auto is-h-s-mobile align to-t' size='small' %}
|
||||
</a>
|
||||
|
||||
<div class="column ml-3 control">
|
||||
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
||||
|
||||
{% join "add_item" list.id book.id as modal_id %}
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-link"
|
||||
data-modal-open="{{ modal_id }}"
|
||||
>
|
||||
{% if is_suggestion or list.curation == 'open' or request.user == list.user or list.group|is_member:request.user %}
|
||||
{% trans "Add" %}
|
||||
{% else %}
|
||||
{% trans "Suggest" %}
|
||||
{% endif %}
|
||||
</button>
|
||||
{% include "lists/add_item_modal.html" with id=modal_id is_suggestion=is_suggestion list=list %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
@ -51,6 +51,11 @@ class BookViews(TestCase):
|
|||
remote_id="https://example.com/book/1",
|
||||
parent_work=cls.work,
|
||||
)
|
||||
cls.another_book = models.Edition.objects.create(
|
||||
title="Another Example Edition",
|
||||
remote_id="https://example.com/book/1",
|
||||
parent_work=models.Work.objects.create(title="Another Work"),
|
||||
)
|
||||
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
@ -134,6 +139,43 @@ class BookViews(TestCase):
|
|||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.context_data["statuses"].object_list[0], quote)
|
||||
|
||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||
def test_book_page_suggestions(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Book.as_view()
|
||||
suggestion_list = models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
with patch("bookwyrm.views.books.books.is_api_request") as is_api:
|
||||
is_api.return_value = False
|
||||
result = view(request, self.book.id, user_statuses="review")
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
validate_html(result.render())
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.context_data["suggestion_list"], suggestion_list)
|
||||
|
||||
@patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async")
|
||||
def test_book_page_suggestions_with_items(self, *_):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Book.as_view()
|
||||
suggestion_list = models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
models.SuggestionListItem.objects.create(
|
||||
book_list=suggestion_list, user=self.local_user, book=self.another_book
|
||||
)
|
||||
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
with patch("bookwyrm.views.books.books.is_api_request") as is_api:
|
||||
is_api.return_value = False
|
||||
result = view(request, self.book.id, user_statuses="review")
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
validate_html(result.render())
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(result.context_data["suggestion_list"], suggestion_list)
|
||||
|
||||
def test_book_page_invalid_id(self):
|
||||
"""there are so many views, this just makes sure it LOADS"""
|
||||
view = views.Book.as_view()
|
||||
|
@ -267,7 +309,7 @@ class BookViews(TestCase):
|
|||
"""make sure the endposition is served as well"""
|
||||
view = views.Book.as_view()
|
||||
|
||||
_ = models.Quotation.objects.create(
|
||||
models.Quotation.objects.create(
|
||||
user=self.local_user,
|
||||
book=self.book,
|
||||
content="hi",
|
||||
|
|
184
bookwyrm/tests/views/test_suggestion_list.py
Normal file
184
bookwyrm/tests/views/test_suggestion_list.py
Normal file
|
@ -0,0 +1,184 @@
|
|||
""" test for app action functionality """
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from bookwyrm import forms, models, views
|
||||
from bookwyrm.activitypub import ActivitypubResponse, get_representative
|
||||
from bookwyrm.tests.validate_html import validate_html
|
||||
|
||||
|
||||
class BookViews(TestCase):
|
||||
"""books books books"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""we need basic test data and mocks"""
|
||||
with (
|
||||
patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"),
|
||||
patch("bookwyrm.activitystreams.populate_stream_task.delay"),
|
||||
patch("bookwyrm.lists_stream.populate_lists_task.delay"),
|
||||
):
|
||||
cls.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",
|
||||
)
|
||||
cls.another_user = models.User.objects.create_user(
|
||||
"rat@local.com",
|
||||
"rat@rat.com",
|
||||
"ratword",
|
||||
local=True,
|
||||
localname="rat",
|
||||
remote_id="https://example.com/users/rat",
|
||||
)
|
||||
cls.work = models.Work.objects.create(title="Test Work")
|
||||
cls.book = models.Edition.objects.create(
|
||||
title="Example Edition",
|
||||
remote_id="https://example.com/book/1",
|
||||
parent_work=cls.work,
|
||||
)
|
||||
cls.another_book = models.Edition.objects.create(
|
||||
title="Another Example Edition",
|
||||
remote_id="https://example.com/book/1",
|
||||
parent_work=models.Work.objects.create(title="Another Work"),
|
||||
)
|
||||
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
def setUp(self):
|
||||
"""individual test setup"""
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_suggestion_list_get(self, *_):
|
||||
"""start a suggestion list for a book"""
|
||||
models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
view = views.SuggestionList.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
|
||||
result = view(request, self.book.id)
|
||||
validate_html(result.render())
|
||||
|
||||
def test_suggestion_list_get_json(self, *_):
|
||||
"""start a suggestion list for a book"""
|
||||
models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
view = views.SuggestionList.as_view()
|
||||
request = self.factory.get("")
|
||||
request.user = self.local_user
|
||||
|
||||
with patch("bookwyrm.views.suggestion_list.is_api_request") as is_api:
|
||||
is_api.return_value = True
|
||||
result = view(request, self.book.id)
|
||||
self.assertIsInstance(result, ActivitypubResponse)
|
||||
|
||||
def test_suggestion_create(self, *_):
|
||||
"""start a suggestion list for a book"""
|
||||
self.assertFalse(hasattr(self.work, "suggestion_list"))
|
||||
|
||||
view = views.SuggestionList.as_view()
|
||||
form = forms.SuggestionListForm()
|
||||
form.data["suggests_for"] = self.work.id
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.local_user
|
||||
|
||||
view(request, self.book.id)
|
||||
|
||||
self.work.refresh_from_db()
|
||||
self.assertTrue(hasattr(self.work, "suggestion_list"))
|
||||
|
||||
suggestion_list = self.work.suggestion_list
|
||||
self.assertEqual(suggestion_list.suggests_for, self.work)
|
||||
self.assertEqual(suggestion_list.privacy, "public")
|
||||
self.assertEqual(suggestion_list.user, get_representative())
|
||||
|
||||
def test_book_add_suggestion(self, *_):
|
||||
"""Add a book to the recommendation list"""
|
||||
suggestion_list = models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
view = views.book_add_suggestion
|
||||
|
||||
form = forms.SuggestionListItemForm()
|
||||
form.data["user"] = self.local_user.id
|
||||
form.data["book"] = self.another_book.id
|
||||
form.data["book_list"] = suggestion_list.id
|
||||
form.data["notes"] = "hello"
|
||||
|
||||
request = self.factory.post("", form.data)
|
||||
request.user = self.local_user
|
||||
|
||||
view(request, self.work.id)
|
||||
|
||||
self.assertEqual(suggestion_list.suggestionlistitem_set.count(), 1)
|
||||
item = suggestion_list.suggestionlistitem_set.first()
|
||||
self.assertEqual(item.book, self.another_book)
|
||||
self.assertEqual(item.user, self.local_user)
|
||||
self.assertEqual(item.notes, "hello")
|
||||
|
||||
def test_book_remove_suggestion(self, *_):
|
||||
"""Remove a book from the recommendation list"""
|
||||
suggestion_list = models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
item = models.SuggestionListItem.objects.create(
|
||||
book_list=suggestion_list, user=self.local_user, book=self.another_book
|
||||
)
|
||||
self.assertEqual(suggestion_list.suggestionlistitem_set.count(), 1)
|
||||
|
||||
view = views.book_remove_suggestion
|
||||
request = self.factory.post("", {"item": item.id})
|
||||
request.user = self.local_user
|
||||
|
||||
view(request, self.work.id)
|
||||
|
||||
self.assertEqual(suggestion_list.suggestionlistitem_set.count(), 0)
|
||||
|
||||
def test_book_remove_suggestion_without_permission(self, *_):
|
||||
"""Remove a book from the recommendation list"""
|
||||
suggestion_list = models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
item = models.SuggestionListItem.objects.create(
|
||||
book_list=suggestion_list, user=self.local_user, book=self.another_book
|
||||
)
|
||||
self.assertEqual(suggestion_list.suggestionlistitem_set.count(), 1)
|
||||
|
||||
view = views.book_remove_suggestion
|
||||
request = self.factory.post("", {"item": item.id})
|
||||
request.user = self.another_user
|
||||
|
||||
with self.assertRaises(PermissionDenied):
|
||||
view(request, self.work.id)
|
||||
|
||||
self.assertEqual(suggestion_list.suggestionlistitem_set.count(), 1)
|
||||
|
||||
def test_endorse_suggestion(self, *_):
|
||||
"""Endorse a suggestion"""
|
||||
suggestion_list = models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
item = models.SuggestionListItem.objects.create(
|
||||
book_list=suggestion_list, user=self.local_user, book=self.another_book
|
||||
)
|
||||
self.assertEqual(item.endorsement.count(), 0)
|
||||
view = views.endorse_suggestion
|
||||
request = self.factory.post("")
|
||||
request.user = self.another_user
|
||||
|
||||
view(request, self.work.id, item.id)
|
||||
|
||||
self.assertEqual(item.endorsement.count(), 1)
|
||||
|
||||
def test_endorse_suggestion_by_self(self, *_):
|
||||
"""Endorse a suggestion error handling"""
|
||||
suggestion_list = models.SuggestionList.objects.create(suggests_for=self.work)
|
||||
item = models.SuggestionListItem.objects.create(
|
||||
book_list=suggestion_list, user=self.local_user, book=self.another_book
|
||||
)
|
||||
self.assertEqual(item.endorsement.count(), 0)
|
||||
view = views.endorse_suggestion
|
||||
request = self.factory.post("")
|
||||
request.user = self.local_user
|
||||
|
||||
view(request, self.work.id, item.id)
|
||||
|
||||
# no impact
|
||||
self.assertEqual(item.endorsement.count(), 0)
|
|
@ -785,6 +785,26 @@ urlpatterns = [
|
|||
views.update_book_from_remote,
|
||||
name="book-update-remote",
|
||||
),
|
||||
re_path(
|
||||
rf"{BOOK_PATH}/suggestions(.json)?/?$",
|
||||
views.SuggestionList.as_view(),
|
||||
name="suggestion-list",
|
||||
),
|
||||
re_path(
|
||||
rf"{BOOK_PATH}/suggestions/add/?$",
|
||||
views.book_add_suggestion,
|
||||
name="book-add-suggestion",
|
||||
),
|
||||
re_path(
|
||||
rf"{BOOK_PATH}/suggestions/remove/?$",
|
||||
views.book_remove_suggestion,
|
||||
name="book-remove-suggestion",
|
||||
),
|
||||
re_path(
|
||||
rf"{BOOK_PATH}/suggestions/endorse/(?P<item_id>\d+)/?$",
|
||||
views.endorse_suggestion,
|
||||
name="suggestion-endorse",
|
||||
),
|
||||
re_path(
|
||||
r"^author/(?P<author_id>\d+)/update/(?P<connector_identifier>[\w\.]+)/?$",
|
||||
views.update_author_from_remote,
|
||||
|
|
|
@ -55,12 +55,7 @@ from .preferences.two_factor_auth import (
|
|||
)
|
||||
|
||||
# books
|
||||
from .books.books import (
|
||||
Book,
|
||||
upload_cover,
|
||||
add_description,
|
||||
resolve_book,
|
||||
)
|
||||
from .books.books import Book, upload_cover, add_description, resolve_book
|
||||
from .books.series import BookSeriesBy
|
||||
from .books.books import update_book_from_remote
|
||||
from .books.edit_book import (
|
||||
|
@ -117,6 +112,14 @@ from .list.list import (
|
|||
set_book_position,
|
||||
)
|
||||
|
||||
# suggestion lists
|
||||
from .suggestion_list import SuggestionList
|
||||
from .suggestion_list import (
|
||||
book_add_suggestion,
|
||||
book_remove_suggestion,
|
||||
endorse_suggestion,
|
||||
)
|
||||
|
||||
# misc views
|
||||
from .author import Author, EditAuthor, update_author_from_remote
|
||||
from .directory import Directory
|
||||
|
|
|
@ -4,7 +4,7 @@ from uuid import uuid4
|
|||
|
||||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Avg, Q
|
||||
from django.db.models import Avg, Q, Count
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
|
@ -23,6 +23,7 @@ from bookwyrm.views.helpers import (
|
|||
maybe_redirect_local_path,
|
||||
get_mergeable_object_or_404,
|
||||
)
|
||||
from bookwyrm.views.list.list import get_list_suggestions
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
|
@ -92,6 +93,7 @@ class Book(View):
|
|||
)
|
||||
data = {
|
||||
"book": book,
|
||||
"work": book.parent_work,
|
||||
"statuses": paginated.get_page(request.GET.get("page")),
|
||||
"review_count": reviews.count(),
|
||||
"ratings": (
|
||||
|
@ -104,6 +106,7 @@ class Book(View):
|
|||
"rating": reviews.aggregate(Avg("rating"))["rating__avg"],
|
||||
"lists": lists,
|
||||
"update_error": kwargs.get("update_error", False),
|
||||
"query": request.GET.get("suggestion_query", ""),
|
||||
}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
|
@ -136,6 +139,23 @@ class Book(View):
|
|||
"comment_count": book.comment_set.filter(**filters).count(),
|
||||
"quotation_count": book.quotation_set.filter(**filters).count(),
|
||||
}
|
||||
if hasattr(book.parent_work, "suggestion_list"):
|
||||
data["suggestion_list"] = book.parent_work.suggestion_list
|
||||
data["items"] = (
|
||||
data["suggestion_list"]
|
||||
.suggestionlistitem_set.prefetch_related(
|
||||
"user", "book", "book__authors", "endorsement"
|
||||
)
|
||||
.annotate(endorsement_count=Count("endorsement"))
|
||||
.order_by("-endorsement_count")[:3]
|
||||
)
|
||||
|
||||
data["suggested_books"] = get_list_suggestions(
|
||||
data["suggestion_list"],
|
||||
request.user,
|
||||
query=request.GET.get("suggestion_query", ""),
|
||||
ignore_book=book.parent_work,
|
||||
)
|
||||
|
||||
return TemplateResponse(request, "book/book.html", data)
|
||||
|
||||
|
|
|
@ -75,11 +75,15 @@ class List(View):
|
|||
"embed_url": embed_url,
|
||||
"add_failed": add_failed,
|
||||
"add_succeeded": add_succeeded,
|
||||
"add_book_url": reverse("list-add-book"),
|
||||
"remove_book_url": reverse("list-remove-book", args=[list_id]),
|
||||
}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
data["suggested_books"] = get_list_suggestions(
|
||||
book_list, request.user, query=query
|
||||
book_list,
|
||||
request.user,
|
||||
query=query,
|
||||
)
|
||||
return TemplateResponse(request, "lists/list.html", data)
|
||||
|
||||
|
@ -91,7 +95,7 @@ class List(View):
|
|||
form = forms.ListForm(request.POST, instance=book_list)
|
||||
if not form.is_valid():
|
||||
# this shouldn't happen
|
||||
raise Exception(form.errors)
|
||||
raise Exception(form.errors) # pylint: disable=broad-exception-raised
|
||||
book_list = form.save(request)
|
||||
if not book_list.curation == "group":
|
||||
book_list.group = None
|
||||
|
@ -100,24 +104,32 @@ class List(View):
|
|||
return redirect_to_referer(request, book_list.local_path)
|
||||
|
||||
|
||||
def get_list_suggestions(book_list, user, query=None, num_suggestions=5):
|
||||
def get_list_suggestions(
|
||||
book_list, user, query=None, num_suggestions=5, ignore_book=None
|
||||
):
|
||||
"""What books might a user want to add to a list"""
|
||||
if query:
|
||||
# search for books
|
||||
return book_search.search(
|
||||
query,
|
||||
filters=[~Q(parent_work__editions__in=book_list.books.all())],
|
||||
filters=[
|
||||
~Q(parent_work__editions__in=book_list.books.all()),
|
||||
~Q(parent_work=ignore_book),
|
||||
],
|
||||
)
|
||||
# just suggest whatever books are nearby
|
||||
suggestions = user.shelfbook_set.filter(
|
||||
~Q(book__in=book_list.books.all())
|
||||
).distinct()[:num_suggestions]
|
||||
suggestions = (
|
||||
user.shelfbook_set.filter(~Q(book__in=book_list.books.all()))
|
||||
.exclude(book__parent_work=ignore_book)
|
||||
.distinct()[:num_suggestions]
|
||||
)
|
||||
suggestions = [s.book for s in suggestions[:num_suggestions]]
|
||||
if len(suggestions) < num_suggestions:
|
||||
others = [
|
||||
s.default_edition
|
||||
for s in models.Work.objects.filter(
|
||||
~Q(editions__in=book_list.books.all()),
|
||||
~Q(id=ignore_book.id if ignore_book else None),
|
||||
)
|
||||
.distinct()
|
||||
.order_by("-updated_date")[:num_suggestions]
|
||||
|
|
|
@ -33,7 +33,6 @@ class Lists(View):
|
|||
return TemplateResponse(request, "lists/lists.html", data)
|
||||
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
# pylint: disable=unused-argument
|
||||
def post(self, request):
|
||||
"""create a book_list"""
|
||||
form = forms.ListForm(request.POST)
|
||||
|
|
147
bookwyrm/views/suggestion_list.py
Normal file
147
bookwyrm/views/suggestion_list.py
Normal file
|
@ -0,0 +1,147 @@
|
|||
""" the good stuff! the books! """
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.http import HttpRequest
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.activitypub import ActivitypubResponse
|
||||
from bookwyrm.settings import PAGE_LENGTH
|
||||
from bookwyrm.views import Book
|
||||
from bookwyrm.views.helpers import is_api_request, redirect_to_referer
|
||||
from bookwyrm.views.list.list import get_list_suggestions
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class SuggestionList(View):
|
||||
"""book list page"""
|
||||
|
||||
def get(
|
||||
self, request: HttpRequest, book_id: int, **kwargs: Any
|
||||
) -> ActivitypubResponse | TemplateResponse:
|
||||
"""display a book list"""
|
||||
add_failed = kwargs.get("add_failed", False)
|
||||
add_succeeded = kwargs.get("add_succeeded", False)
|
||||
|
||||
work = models.Work.objects.filter(
|
||||
Q(id=book_id) | Q(editions=book_id)
|
||||
).distinct()
|
||||
work = work.first()
|
||||
|
||||
book_list = get_object_or_404(models.SuggestionList, suggests_for=work)
|
||||
|
||||
if is_api_request(request):
|
||||
return ActivitypubResponse(book_list.to_activity(**request.GET))
|
||||
|
||||
items = (
|
||||
book_list.suggestionlistitem_set.prefetch_related(
|
||||
"user", "book", "book__authors"
|
||||
)
|
||||
.annotate(endorsement_count=Count("endorsement"))
|
||||
.order_by("-endorsement_count")
|
||||
)
|
||||
|
||||
paginated = Paginator(items, PAGE_LENGTH)
|
||||
|
||||
page = paginated.get_page(request.GET.get("page"))
|
||||
|
||||
embed_key = str(book_list.embed_key.hex) # type: ignore
|
||||
embed_url = reverse("embed-list", args=[book_list.id, embed_key])
|
||||
embed_url = request.build_absolute_uri(embed_url)
|
||||
|
||||
if request.GET:
|
||||
embed_url = f"{embed_url}?{request.GET.urlencode()}"
|
||||
|
||||
query = request.GET.get("q", "")
|
||||
data = {
|
||||
"list": book_list,
|
||||
"work": book_list.suggests_for,
|
||||
"items": page,
|
||||
"page_range": paginated.get_elided_page_range(
|
||||
page.number, on_each_side=2, on_ends=1
|
||||
),
|
||||
"query": query,
|
||||
"embed_url": embed_url,
|
||||
"add_failed": add_failed,
|
||||
"add_succeeded": add_succeeded,
|
||||
"add_book_url": reverse("book-add-suggestion", args=[book_id]),
|
||||
"remove_book_url": reverse("book-remove-suggestion", args=[book_id]),
|
||||
}
|
||||
|
||||
if request.user.is_authenticated:
|
||||
data["suggested_books"] = get_list_suggestions(
|
||||
book_list, request.user, query=query, ignore_book=book_list.suggests_for
|
||||
)
|
||||
return TemplateResponse(request, "lists/list.html", data)
|
||||
|
||||
@method_decorator(login_required, name="dispatch")
|
||||
def post(
|
||||
self, request: HttpRequest, book_id: int # pylint: disable=unused-argument
|
||||
) -> Any:
|
||||
"""create a suggestion_list"""
|
||||
form = forms.SuggestionListForm(request.POST)
|
||||
|
||||
if not form.is_valid():
|
||||
return redirect_to_referer(request)
|
||||
# saving in two steps means django uses the model's custom save functionality,
|
||||
# which adds an embed key and fixes the privacy and curation settings
|
||||
suggestion_list = form.save(request, commit=False)
|
||||
suggestion_list.save()
|
||||
|
||||
return redirect_to_referer(request)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def book_add_suggestion(request: HttpRequest, book_id: int) -> Any:
|
||||
"""put a book on the suggestion list"""
|
||||
_ = get_object_or_404(
|
||||
models.SuggestionList, suggests_for=book_id, id=request.POST.get("book_list")
|
||||
)
|
||||
|
||||
form = forms.SuggestionListItemForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return Book().get(request, book_id, add_failed=True)
|
||||
|
||||
form.save(request)
|
||||
|
||||
return redirect_to_referer(request)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def book_remove_suggestion(request: HttpRequest, book_id: int) -> Any:
|
||||
"""remove a book from a suggestion list"""
|
||||
item = get_object_or_404(
|
||||
models.SuggestionListItem,
|
||||
id=request.POST.get("item"),
|
||||
book_list__suggests_for=book_id,
|
||||
)
|
||||
item.raise_not_deletable(request.user)
|
||||
|
||||
with transaction.atomic():
|
||||
item.delete()
|
||||
|
||||
return redirect_to_referer(request)
|
||||
|
||||
|
||||
@require_POST
|
||||
@login_required
|
||||
def endorse_suggestion(request: HttpRequest, book_id: int, item_id: int) -> Any:
|
||||
"""endorse a suggestion"""
|
||||
item = get_object_or_404(
|
||||
models.SuggestionListItem, id=item_id, book_list__suggests_for=book_id
|
||||
)
|
||||
if request.user not in item.endorsement.all():
|
||||
item.endorse(request.user)
|
||||
else:
|
||||
item.unendorse(request.user)
|
||||
return redirect_to_referer(request)
|
6
mypy.ini
6
mypy.ini
|
@ -22,6 +22,12 @@ ignore_errors = False
|
|||
[mypy-bookwyrm.isbn.*]
|
||||
ignore_errors = False
|
||||
|
||||
[mypy-bookwyrm.views.suggestion_list]
|
||||
ignore_errors = False
|
||||
allow_untyped_calls = True
|
||||
disable_error_code = assignment
|
||||
|
||||
|
||||
[mypy-celerywyrm.*]
|
||||
ignore_errors = False
|
||||
|
||||
|
|
Loading…
Reference in a new issue