1
0
Fork 1
mirror of https://github.com/bookwyrm-social/bookwyrm.git synced 2025-04-23 18:54:10 +00:00
This commit is contained in:
Mouse Reeve 2025-04-02 23:46:35 +00:00 committed by GitHub
commit f050b5a221
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1026 additions and 167 deletions

View file

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

View file

@ -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):

View file

@ -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=(

View file

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

View file

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

View file

@ -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:

View file

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

View file

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

View 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>

View file

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View 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)

View file

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

View file

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

View file

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

View file

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

View file

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

View 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)

View file

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