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" %}
@@ -453,8 +453,11 @@ {% 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 %}
+
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 %}
+
+
+
+ {% 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 %}
+
+ {% include "book/suggestion_list/book_card.html" with list=suggestion_list %}
+
+ {% endfor %}
+
+
+
+ {% include "book/suggestion_list/search.html" with list=suggestion_list is_suggestion=True %}
+
+ {% endif %}
+{% else %}
+
+
+{% 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 @@
+ {% 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 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 %}
-
- {% 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 %}
+
+
+
+ {% include "snippets/avatar.html" with user=item.user %}
+
+
+ {% 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 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 %}
+
+ {% 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)