This commit is contained in:
Joachim 2024-03-30 14:11:43 -04:00 committed by GitHub
commit ad2694abbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 314 additions and 29 deletions

View file

@ -40,6 +40,7 @@ class BookList(OrderedCollectionPrivate):
summary: str = None
curation: str = "closed"
book: str = None
type: str = "BookList"

View file

@ -14,6 +14,12 @@ class ListForm(CustomForm):
fields = ["user", "name", "description", "curation", "privacy", "group"]
class SuggestionListForm(CustomForm):
class Meta:
model = models.List
fields = ["user", "suggests_for"]
class ListItemForm(CustomForm):
class Meta:
model = models.ListItem

View file

@ -0,0 +1,26 @@
# Generated by Django 3.2.20 on 2023-08-01 13:12
import bookwyrm.models.fields
from django.db import migrations
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0179_populate_sort_title"),
]
operations = [
migrations.AddField(
model_name="list",
name="suggests_for",
field=bookwyrm.models.fields.OneToOneField(
default=None,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="suggestion_list",
to="bookwyrm.edition",
),
),
]

View file

@ -302,7 +302,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:
@ -624,11 +624,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

@ -5,6 +5,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 DOMAIN
@ -47,6 +48,14 @@ class List(OrderedCollectionMixin, BookWyrmModel):
)
embed_key = models.UUIDField(unique=True, null=True, editable=False)
activity_serializer = activitypub.BookList
suggests_for = fields.OneToOneField(
"Edition",
on_delete=models.PROTECT,
activitypub_field="book",
related_name="suggestion_list",
default=None,
null=True,
)
def get_remote_id(self):
"""don't want the user to be in there in this case"""
@ -57,6 +66,27 @@ class List(OrderedCollectionMixin, BookWyrmModel):
"""list of books for this shelf, overrides OrderedCollectionMixin"""
return self.books.filter(listitem__approved=True).order_by("listitem")
@property
def get_name(self):
"""The name comes from the book title if it's a suggestion list"""
if self.suggests_for:
return _("Suggestions for %(title)s") % {"title": self.suggests_for.title}
return self.name
@property
def get_description(self):
"""The description comes from the book title if it's a suggestion list"""
if self.suggests_for:
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,
}
return self.description
class Meta:
"""default sorting"""

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" %}
</section>
{% endwith %}
{% endblock %}

View file

@ -0,0 +1,66 @@
{% load i18n %}
<h2 class="title is-3" id="suggestions-section">
{% trans "Suggestions" %}
</h2>
{% if book.suggestion_list %}
{% with book.suggestion_list.listitem_set.all as items %}
{% if items|length == 0 %}
<section class="section has-background-light is-flex is-flex-direction-column is-align-items-center">
<div class="column is-one-third is-centered">
{% include "book/suggestion_list/search.html" %}
</div>
</section>
{% else %}
<ol class="columns">
{% for item in items %}
<li class="mb-5 column is-one-quarter">
<div class="card">
<div class="card-content pb-3">
{% with book=item.book %}
<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' %}
</a>
</div>
<p class="mt-3">
{% include 'snippets/book_titleby.html' %}
</p>
{% endwith %}
</div>
<div class="card-footer is-stacked-mobile has-background-tertiary is-align-items-stretch">
<div class="card-footer-item">
<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">
<p>
TODO :: endorsements
</p>
</div>
</div>
</div>
</li>
{% endfor %}
<li class="mb-5 column is-one-quarter">
{% include "book/suggestion_list/search.html" %}
</li>
</ol>
{% endif %}
{% endwith %}
{% else %}
<section class="section is-medium has-background-light">
<form name="create-list" method="post" action="{% url 'book-create-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="{{ book.id }}">
<button type="submit" class="button is-medium">{% trans "Create suggestion list" %}</button>
</form>
</section>
{% endif %}

View file

@ -0,0 +1,54 @@
{% load i18n %}
{% load utilities %}
{% if request.user.is_authenticated %}
{% with book.suggestion_list as list %}
<h2 class="title is-5">
{% trans "Add suggestions" %}
</h2>
<form name="search" action="{% url 'book' book_id=book.id %}#suggestions-section" 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="suggestion_query" placeholder="{% trans 'Search for a book' %}" value="{{ query }}">
</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 'book' book_id=book.id %}#suggestions-section">{% 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">
<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 }}"
>
{% trans "Add" %}
</button>
{% include "lists/add_item_modal.html" with id=modal_id is_suggestion=True %}
</div>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endif %}

View file

@ -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 %}{% if query %}?suggestion_query={{ query }}#suggestions-section{% endif %}"
{% else %}
action="{% url 'list-add-book' %}{% if query %}?q={{ query }}{% endif %}"
{% endif %}
>
{% endblock %}

View file

@ -21,7 +21,7 @@
</p>
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=list.description %}
{% include 'snippets/trimmed_text.html' with full=list.get_description %}
</div>
<section>

View file

@ -2,7 +2,7 @@
{% load i18n %}
{% load list_page_tags %}
{% block title %}{{ list.name }}{% endblock %}
{% block title %}{{ list.get_name }}{% endblock %}
{% block opengraph %}
{% include 'snippets/opengraph.html' with title=list|opengraph_title description=list|opengraph_description %}
@ -11,14 +11,16 @@
{% block content %}
<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>
<h1 class="title">{{ list.get_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" %}
@ -33,7 +35,7 @@
{% block breadcrumbs %}{% endblock %}
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=list.description %}
{% include 'snippets/trimmed_text.html' with full=list.get_description %}
</div>
<div class="block">

View file

@ -12,7 +12,7 @@
<li><a href="{% url 'lists' %}">{% trans "Lists" %}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{{ list.name|truncatechars:30 }}
{{ list.get_name|truncatechars:30 }}
</a>
</li>
</ul>
@ -177,6 +177,7 @@
</section>
<section class="column is-one-quarter">
{% if list.suggests_for == None %}
<h2 class="title is-5">
{% trans "Sort List" %}
</h2>
@ -199,6 +200,7 @@
</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 %}
@ -275,7 +277,7 @@
data-copytext
data-copytext-label="{% trans 'Copy embed code' %}"
data-copytext-success="{% trans 'Copied!' %}"
>&lt;iframe style="border-width:0;" id="bookwyrm_list_embed" width="400" height="600" title="{% blocktrans trimmed with list_name=list.name site_name=site.name owner=list.user.display_name %}
>&lt;iframe style="border-width:0;" id="bookwyrm_list_embed" width="400" height="600" title="{% blocktrans trimmed with list_name=list.get_name site_name=site.name owner=list.user.display_name %}
{{ list_name }}, a list by {{owner}} on {{ site_name }}
{% endblocktrans %}" src="{{ embed_url }}"&gt;&lt;/iframe&gt;</textarea>
</div>

View file

@ -8,7 +8,7 @@
<div class="card is-stretchable">
<header class="card-header">
<h4 class="card-header-title is-clipped">
<a href="{{ list.local_path }}" class="is-clipped">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
<a href="{{ list.local_path }}" class="is-clipped">{{ list.get_name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h4>
{% if request.user.is_authenticated and request.user|saved:list %}
<div class="card-header-icon">
@ -33,9 +33,9 @@
{% endwith %}
<div class="card-content is-flex-grow-0">
<div class="is-clipped" {% if list.description %}title="{{ list.description }}"{% endif %}>
{% if list.description %}
{{ list.description|to_markdown|safe|truncatechars_html:30 }}
<div class="is-clipped" {% if list.description %}title="{{ list.get_description }}"{% endif %}>
{% if list.get_description %}
{{ list.get_description|to_markdown|safe|truncatechars_html:30 }}
{% else %}
&nbsp;
{% endif %}

View file

@ -760,6 +760,16 @@ urlpatterns = [
views.update_book_from_remote,
name="book-update-remote",
),
re_path(
rf"{BOOK_PATH}/create-suggestion-list/?$",
views.create_suggestion_list,
name="book-create-suggestion-list",
),
re_path(
rf"{BOOK_PATH}/book-add-suggestion/?$",
views.book_add_suggestion,
name="book-add-suggestion",
),
re_path(
r"^author/(?P<author_id>\d+)/update/(?P<connector_identifier>[\w\.]+)/?$",
views.update_author_from_remote,

View file

@ -60,6 +60,8 @@ from .books.books import (
upload_cover,
add_description,
resolve_book,
create_suggestion_list,
book_add_suggestion,
)
from .books.series import BookSeriesBy
from .books.books import update_book_from_remote

View file

@ -3,7 +3,8 @@ 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 import transaction
from django.db.models import Avg, Q, Max
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
@ -16,6 +17,7 @@ from bookwyrm.connectors import connector_manager, ConnectorException
from bookwyrm.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request, maybe_redirect_local_path
from bookwyrm.views.list.list import get_list_suggestions, increment_order_in_reverse
# pylint: disable=no-self-use
@ -74,9 +76,15 @@ class Book(View):
queryset = queryset.select_related("user").order_by("-published_date")
paginated = Paginator(queryset, PAGE_LENGTH)
lists = models.List.privacy_filter(request.user,).filter(
listitem__approved=True,
listitem__book__in=book.parent_work.editions.all(),
lists = (
models.List.privacy_filter(
request.user,
)
.filter(
listitem__approved=True,
listitem__book__in=book.parent_work.editions.all(),
)
.filter(suggests_for__isnull=True)
)
data = {
"book": book,
@ -90,10 +98,13 @@ 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:
data["list_options"] = request.user.list_set.exclude(id__in=data["lists"])
data["list_options"] = request.user.list_set.filter(
suggests_for__isnull=True
).exclude(id__in=data["lists"])
data["file_link_form"] = forms.FileLinkForm()
readthroughs = models.ReadThrough.objects.filter(
user=request.user,
@ -122,6 +133,13 @@ class Book(View):
"comment_count": book.comment_set.filter(**filters).count(),
"quotation_count": book.quotation_set.filter(**filters).count(),
}
if hasattr(book, "suggestion_list"):
data["suggested_books"] = get_list_suggestions(
book.suggestion_list,
request.user,
query=request.GET.get("suggestion_query", ""),
ignore_book=book,
)
return TemplateResponse(request, "book/book.html", data)
@ -208,3 +226,48 @@ def update_book_from_remote(request, book_id, connector_identifier):
return Book().get(request, book_id, update_error=True)
return redirect("book", book.id)
@login_required
@require_POST
def create_suggestion_list(request, book_id):
"""create a suggestion_list"""
form = forms.SuggestionListForm(request.POST)
book = get_object_or_404(models.Edition, id=book_id)
if not form.is_valid():
return redirect("book", book.id)
suggestion_list = form.save(request, commit=False)
# default values for the suggestion list
suggestion_list.privacy = "public"
suggestion_list.curation = "open"
suggestion_list.save()
return redirect("book", book.id)
@login_required
@require_POST
@transaction.atomic
def book_add_suggestion(request, book_id):
"""put a book on the suggestion list"""
book_list = get_object_or_404(models.List, id=request.POST.get("book_list"))
form = forms.ListItemForm(request.POST)
if not form.is_valid():
return Book().get(request, book_id, add_failed=True)
item = form.save(request, commit=False)
# add the book at the latest order of approved books, before pending books
order_max = (
book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[
"order__max"
]
) or 0
increment_order_in_reverse(book_list.id, order_max + 1)
item.order = order_max + 1
item.save()
return Book().get(request, book_id, add_succeeded=True)

View file

@ -77,7 +77,10 @@ class List(View):
if request.user.is_authenticated:
data["suggested_books"] = get_list_suggestions(
book_list, request.user, query=query
book_list,
request.user,
query=query,
ignore_book=book_list.suggests_for,
)
return TemplateResponse(request, "lists/list.html", data)
@ -98,24 +101,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__editions__in=[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=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(editions__in=[ignore_book]),
)
.distinct()
.order_by("-updated_date")[:num_suggestions]

View file

@ -21,6 +21,7 @@ class Lists(View):
lists = ListsStream().get_list_stream(request.user)
else:
lists = models.List.objects.filter(privacy="public")
lists = lists.filter(suggests_for__isnull=True)
paginated = Paginator(lists, 12)
data = {
"lists": paginated.get_page(request.GET.get("page")),
@ -71,7 +72,11 @@ class UserLists(View):
def get(self, request, username):
"""display a book list"""
user = get_user_from_username(request.user, username)
lists = models.List.privacy_filter(request.user).filter(user=user)
lists = (
models.List.privacy_filter(request.user)
.filter(user=user)
.filter(suggests_for__isnull=True)
)
paginated = Paginator(lists, 12)
data = {