diff --git a/bookwyrm/activitypub/__init__.py b/bookwyrm/activitypub/__init__.py index 70f1097420..b311d27a26 100644 --- a/bookwyrm/activitypub/__init__.py +++ b/bookwyrm/activitypub/__init__.py @@ -15,8 +15,8 @@ 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 CollectionItem, ListItem, ShelfItem, SuggestionListItem +from .ordered_collection import BookList, SuggestionList, Shelf from .person import Person, PublicKey from .response import ActivitypubResponse from .book import Edition, Work, Author diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py index c955aa35ea..4f81002607 100644 --- a/bookwyrm/activitypub/ordered_collection.py +++ b/bookwyrm/activitypub/ordered_collection.py @@ -4,6 +4,7 @@ from typing import List from .base_activity import ActivityObject +from .book import Work @dataclass(init=False) @@ -42,6 +43,15 @@ class BookList(OrderedCollectionPrivate): type: str = "BookList" +@dataclass(init=False) +class SuggestionList(OrderedCollectionPrivate): + """structure of an ordered collection activity""" + + summary: str = None + book: Work = None + type: str = "SuggestionList" + + @dataclass(init=False) class OrderedCollectionPage(ActivityObject): """structure of an ordered collection activity""" @@ -72,6 +82,15 @@ class ListItem(CollectionItem): type: str = "ListItem" +@dataclass(init=False) +class SuggestionListItem(CollectionItem): + """a book on a list""" + + book: str + notes: str = None + type: str = "SuggestionListItem" + + @dataclass(init=False) class ShelfItem(CollectionItem): """a book on a list""" diff --git a/bookwyrm/forms/lists.py b/bookwyrm/forms/lists.py index 6c2a38c816..4d5281912c 100644 --- a/bookwyrm/forms/lists.py +++ b/bookwyrm/forms/lists.py @@ -20,6 +20,18 @@ class Meta: 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=( diff --git a/bookwyrm/migrations/0219_alter_listitem_options_suggestionlist_and_more.py b/bookwyrm/migrations/0219_alter_listitem_options_suggestionlist_and_more.py new file mode 100644 index 0000000000..24146ecfe0 --- /dev/null +++ b/bookwyrm/migrations/0219_alter_listitem_options_suggestionlist_and_more.py @@ -0,0 +1,154 @@ +# Generated by Django 5.2.3 on 2025-10-15 18:14 + +import bookwyrm.models.activitypub_mixin +import bookwyrm.models.fields +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0218_merge_0217_merge_20250816_0749_0217_usersession"), + ] + + operations = [ + migrations.AlterModelOptions( + name="listitem", + options={}, + ), + 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, + ), + ), + ( + "suggests_for", + bookwyrm.models.fields.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name="suggestion_list", + to="bookwyrm.work", + ), + ), + ( + "user", + bookwyrm.models.fields.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ("-updated_date",), + "abstract": False, + }, + bases=( + bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, + models.Model, + ), + ), + 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", + through_fields=("book_list", "book"), + to="bookwyrm.edition", + ), + ), + ] diff --git a/bookwyrm/migrations/0224_merge_20251116_1928.py b/bookwyrm/migrations/0224_merge_20251116_1928.py new file mode 100644 index 0000000000..94b6b1c279 --- /dev/null +++ b/bookwyrm/migrations/0224_merge_20251116_1928.py @@ -0,0 +1,13 @@ +# Generated by Django 5.2.3 on 2025-11-16 19:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0219_alter_listitem_options_suggestionlist_and_more"), + ("bookwyrm", "0223_sitesettings_disable_federation"), + ] + + operations = [] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index a21f847623..43d1b9bdc2 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -10,6 +10,7 @@ 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 diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index d4812f1faa..1554ad7474 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -636,11 +636,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: diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index 3050076401..9ef17165a5 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -8,6 +8,7 @@ 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 @@ -24,15 +25,101 @@ ) -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 %(title)s" + ) % { + "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 ) @@ -43,28 +130,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}" indexes = [Index(fields=["privacy", "-updated_date"])] @@ -130,44 +204,59 @@ def remove_from_group(cls, owner, user): 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 @@ -176,9 +265,23 @@ def raise_not_deletable(self, viewer): 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") + activity_serializer = activitypub.SuggestionListItem diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index ce17c6a424..3626b47a4f 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -414,7 +414,7 @@

{% trans "Places" %}

{% endif %} - {% if lists.exists or request.user.list_set.exists %} + {% if lists.exists or list_options.exists %}

{% trans "Lists" %}

- + +
+ {% include "book/suggestion_list/list.html" with list=suggstion_list %} +
{% endwith %} {% endblock %} diff --git a/bookwyrm/templates/book/suggestion_list/book_card.html b/bookwyrm/templates/book/suggestion_list/book_card.html new file mode 100644 index 0000000000..e0e732ff90 --- /dev/null +++ b/bookwyrm/templates/book/suggestion_list/book_card.html @@ -0,0 +1,46 @@ +{% load i18n %} +{% load book_display_tags %} + +
+
+ {% with item_book=item.book %} + +
+

+ {% include 'snippets/book_titleby.html' with book=item_book %} +

+ {% if item.notes %} + {% include "lists/list_item_notes.html" with list=book.suggestion_list hide_edit=True no_trim=False trim_length=15 %} + {% else %} +
+ {% with full=item_book|book_description %} + {% include 'snippets/trimmed_text.html' with trim_length=15 hide_more=True %} + {% endwith %} +
+ {% endif %} +
+ {% endwith %} +
+ +
+ diff --git a/bookwyrm/templates/book/suggestion_list/endorsement_button.html b/bookwyrm/templates/book/suggestion_list/endorsement_button.html new file mode 100644 index 0000000000..6d5bdb82f7 --- /dev/null +++ b/bookwyrm/templates/book/suggestion_list/endorsement_button.html @@ -0,0 +1,10 @@ +{% load i18n %} +
+ {% csrf_token %} + +
diff --git a/bookwyrm/templates/book/suggestion_list/list.html b/bookwyrm/templates/book/suggestion_list/list.html new file mode 100644 index 0000000000..153d0ed6f7 --- /dev/null +++ b/bookwyrm/templates/book/suggestion_list/list.html @@ -0,0 +1,54 @@ +{% load i18n %} +{% load humanize %} + +{% if request.user.is_authenticated or suggestion_list %} +

+ {% trans "Suggestions" %} + {% if suggestion_list and items|length > 0 %} + + {% trans "View all suggestions" %} ({{ items|length }}) + + {% endif %} +

+ +

+ {% blocktrans trimmed with title=book.title %} + Readers who liked {{ title }} recommend giving these books a try: + {% endblocktrans %} +

+ +{% if suggestion_list %} + + {% if items|length == 0 %} +
+
+

{% trans "Have a recommendation for someone who liked this book?" %}

+ {% include "book/suggestion_list/search.html" with list=suggestion_list is_suggestion=True %} +
+
+ {% else %} +
    + {% for item in items %} +
  1. + {% include "book/suggestion_list/book_card.html" with list=suggestion_list %} +
  2. + {% endfor %} +
+ +
+ {% include "book/suggestion_list/search.html" with list=suggestion_list is_suggestion=True %} +
+ {% endif %} +{% else %} + +
+
+ {% csrf_token %} + + +

{% trans "Have a recommendation for someone who liked this book?" %}

+ +
+
+{% endif %} +{% endif %} diff --git a/bookwyrm/templates/book/suggestion_list/search.html b/bookwyrm/templates/book/suggestion_list/search.html new file mode 100644 index 0000000000..9a5b9cb2ab --- /dev/null +++ b/bookwyrm/templates/book/suggestion_list/search.html @@ -0,0 +1,17 @@ +{% load i18n %} +{% load utilities %} + +{% if request.user.is_authenticated %} +
+ + + {% trans "Add suggestions" %} + + + + {% with search_url=request.path|add:"#add-suggestions" %} + {% include "lists/suggestion_search.html" with is_suggestion=True query_param="suggestion_query" columns=True %} + {% endwith %} +
+{% endif %} + diff --git a/bookwyrm/templates/lists/add_item_modal.html b/bookwyrm/templates/lists/add_item_modal.html index 2c586b308c..96336809e0 100644 --- a/bookwyrm/templates/lists/add_item_modal.html +++ b/bookwyrm/templates/lists/add_item_modal.html @@ -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 "{{ title }}" to this list {% endblocktrans %} @@ -19,7 +19,11 @@
{% endblock %} @@ -35,7 +39,7 @@
+ {% endif %} {% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}

- {% 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 %}

-
-
-
- -
-
- -
-
- {% if query %} -

{% trans "Clear search" %}

- {% endif %} -
- {% if not suggested_books %} - {% if query %} -

{% blocktrans %}No books found matching the query "{{ query }}"{% endblocktrans %}

{% else %} -

{% trans "No books found" %}

- {% endif %} - {% endif %} - - {% if suggested_books|length > 0 %} - {% for book in suggested_books %} -
- - -
-

{% include 'snippets/book_titleby.html' with book=book %}

- - {% join "add_item" list.id book.id as modal_id %} - - {% include "lists/add_item_modal.html" with id=modal_id %} -
-
- {% endfor %} - {% endif %} + {% include "lists/suggestion_search.html" with query_param="q" search_url=request.path is_suggestion=list.suggests_for %} {% endif %}

diff --git a/bookwyrm/templates/lists/list_item_notes.html b/bookwyrm/templates/lists/list_item_notes.html new file mode 100644 index 0000000000..dd03c8cc9a --- /dev/null +++ b/bookwyrm/templates/lists/list_item_notes.html @@ -0,0 +1,49 @@ +{% load i18n %} +{% load utilities %} + +{% if item.notes %} +
+
+ +
+ {% url 'user-feed' item.user|username as user_path %} + {% blocktrans trimmed with username=item.user.display_name %} + {{ username }} says: + {% endblocktrans %} +
+
+
+ + {% include 'snippets/trimmed_text.html' with no_trim=no_trim full=item.notes %} + + {% if item.user == request.user and not hide_edit %} +
+
+ + + {% trans "Edit notes" %} + + + + {% include "lists/edit_item_form.html" with book=item.book %} +
+
+ {% endif %} +
+
+{% elif item.user == request.user and not hide_edit %} +
+
+ + + {% trans "Add notes" %} + + + + {% include "lists/edit_item_form.html" with book=item.book %} +
+
+{% endif %} + diff --git a/bookwyrm/templates/lists/suggestion_search.html b/bookwyrm/templates/lists/suggestion_search.html new file mode 100644 index 0000000000..b852d8b175 --- /dev/null +++ b/bookwyrm/templates/lists/suggestion_search.html @@ -0,0 +1,64 @@ +{% load i18n %} +{% load utilities %} +{% load group_tags %} + +
+
+
+ +
+
+ +
+
+ {% if query %} +

{% trans "Clear search" %}

+ {% endif %} +
+{% if not suggested_books %} +{% if query %} +

{% blocktrans %}No books found matching the query "{{ query }}"{% endblocktrans %}

{% else %} +

{% trans "No books found" %}

+{% endif %} +{% endif %} + +{% if suggested_books|length > 0 %} + + {% for book in suggested_books %} +
+
+ + +
+

{% include 'snippets/book_titleby.html' with book=book %}

+ + {% join "add_item" list.id book.id as modal_id %} + + {% include "lists/add_item_modal.html" with id=modal_id is_suggestion=is_suggestion list=list %} +
+
+
+ {% endfor %} +

+{% endif %} + diff --git a/bookwyrm/tests/views/books/test_book.py b/bookwyrm/tests/views/books/test_book.py index 7df079ce30..8f513eee15 100644 --- a/bookwyrm/tests/views/books/test_book.py +++ b/bookwyrm/tests/views/books/test_book.py @@ -52,6 +52,11 @@ def setUpTestData(cls): 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"), + ) def setUp(self): """individual test setup""" @@ -133,6 +138,43 @@ def test_book_page_statuses(self, *_): 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() @@ -266,7 +308,7 @@ def test_quotation_endposition(self, *_): """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", diff --git a/bookwyrm/tests/views/inbox/test_inbox_add.py b/bookwyrm/tests/views/inbox/test_inbox_add.py index 0f8776774a..24504d44f9 100644 --- a/bookwyrm/tests/views/inbox/test_inbox_add.py +++ b/bookwyrm/tests/views/inbox/test_inbox_add.py @@ -45,6 +45,11 @@ def setUpTestData(cls): remote_id="https://example.com/book/37292", parent_work=work, ) + cls.another_book = models.Edition.objects.create( + title="Another Test", + remote_id="https://example.com/book/79", + parent_work=models.Work.objects.create(title="blah"), + ) @responses.activate def test_handle_add_book_to_shelf(self): @@ -133,3 +138,58 @@ def test_handle_add_book_to_list(self): self.assertEqual(booklist.books.first(), self.book) self.assertEqual(listitem.remote_id, "https://example.com/listbook/6189") self.assertEqual(listitem.notes, "hi hello") + + # pylint: disable=line-too-long + @responses.activate + def test_handle_add_book_to_suggestion_list(self): + """listing a book""" + responses.add( + responses.GET, + "https://example.com/book/suggestion/list", + json={ + "id": "https://example.com/book/{self.another_book.id}/suggestions", + "type": "SuggestionList", + "totalItems": 1, + "book": self.another_book.parent_work.to_activity(), + "first": f"https://example.com/book/{self.another_book.id}/suggestions?page=1", + "last": f"https://example.com/book/{self.another_book.id}/suggestions?page=1", + "name": "Test List", + "owner": "https://example.com/user/mouse", + "to": ["https://www.w3.org/ns/activitystreams#Public"], + "cc": ["https://example.com/user/mouse/followers"], + "summary": "summary text", + "curation": "open", + "@context": "https://www.w3.org/ns/activitystreams", + }, + ) + responses.add( + responses.GET, + self.another_book.parent_work.remote_id, + json=self.another_book.parent_work.to_activity(), + ) + + activity = { + "id": f"https://example.com/book/{self.another_book.id}/suggestions#add", + "type": "Add", + "actor": "https://example.com/users/rat", + "object": { + "actor": self.remote_user.remote_id, + "type": "SuggestionListItem", + "book": self.book.remote_id, + "id": "https://example.com/list/suggestion/item", + "notes": "hi hello", + "order": 1, + }, + "target": "https://example.com/book/suggestion/list", + "@context": "https://www.w3.org/ns/activitystreams", + } + views.inbox.activity_task(activity) + + booklist = models.SuggestionList.objects.get() + listitem = models.SuggestionListItem.objects.get() + self.assertEqual( + booklist.name, f"Suggestions for {self.another_book.parent_work.title}" + ) + self.assertEqual(booklist.suggests_for, self.another_book.parent_work) + self.assertEqual(booklist.books.first(), self.book) + self.assertEqual(listitem.notes, "hi hello") diff --git a/bookwyrm/tests/views/test_suggestion_list.py b/bookwyrm/tests/views/test_suggestion_list.py new file mode 100644 index 0000000000..107b488413 --- /dev/null +++ b/bookwyrm/tests/views/test_suggestion_list.py @@ -0,0 +1,185 @@ +"""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) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 0eab4821a4..b1635e7786 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -888,6 +888,26 @@ 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\d+)/?$", + views.endorse_suggestion, + name="suggestion-endorse", + ), re_path( r"^author/(?P\d+)/update/(?P[\w\.]+)/?$", views.update_author_from_remote, diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index cb560cfa1a..a606bd661b 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -80,12 +80,7 @@ ) # 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 ( @@ -142,6 +137,14 @@ 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 diff --git a/bookwyrm/views/books/books.py b/bookwyrm/views/books/books.py index 9315fe0196..3f546167a9 100644 --- a/bookwyrm/views/books/books.py +++ b/bookwyrm/views/books/books.py @@ -2,7 +2,7 @@ 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 @@ -20,6 +20,7 @@ maybe_redirect_local_path, get_mergeable_object_or_404, ) +from bookwyrm.views.list.list import get_list_suggestions class Book(View): @@ -90,6 +91,7 @@ def get(self, request, book_id, **kwargs): ) data = { "book": book, + "work": book.parent_work, "statuses": paginated.get_page(request.GET.get("page")), "review_count": reviews.count(), "ratings": ( @@ -102,6 +104,7 @@ def get(self, request, book_id, **kwargs): "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: @@ -135,6 +138,23 @@ def get(self, request, book_id, **kwargs): "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) diff --git a/bookwyrm/views/list/list.py b/bookwyrm/views/list/list.py index 10c2e763df..b83888c1ed 100644 --- a/bookwyrm/views/list/list.py +++ b/bookwyrm/views/list/list.py @@ -75,11 +75,15 @@ def get(self, request, list_id, **kwargs): "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 @@ def post(self, request, list_id): 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 @@ def post(self, request, list_id): 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] diff --git a/bookwyrm/views/suggestion_list.py b/bookwyrm/views/suggestion_list.py new file mode 100644 index 0000000000..949ef8fc02 --- /dev/null +++ b/bookwyrm/views/suggestion_list.py @@ -0,0 +1,151 @@ +"""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)