forked from mirrors/bookwyrm
2f38af4faa
This reverts commit 41f27a4a66
.
I forgot that update() can only be done on a query result, not on an object, so we will need to go back to querying in order to update rather than saving.
446 lines
15 KiB
Python
446 lines
15 KiB
Python
""" book list views"""
|
|
from typing import Optional
|
|
from urllib.parse import urlencode
|
|
|
|
from django.contrib.auth.decorators import login_required
|
|
from django.core.paginator import Paginator
|
|
from django.db import IntegrityError, transaction
|
|
from django.db.models import Avg, Count, DecimalField, Q, Max
|
|
from django.db.models.functions import Coalesce
|
|
from django.http import HttpResponseBadRequest, HttpResponse
|
|
from django.shortcuts import get_object_or_404, redirect
|
|
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 book_search, forms, models
|
|
from bookwyrm.activitypub import ActivitypubResponse
|
|
from bookwyrm.settings import PAGE_LENGTH
|
|
from .helpers import is_api_request
|
|
from .helpers import get_user_from_username
|
|
|
|
|
|
# pylint: disable=no-self-use
|
|
class Lists(View):
|
|
"""book list page"""
|
|
|
|
def get(self, request):
|
|
"""display a book list"""
|
|
# hide lists with no approved books
|
|
lists = (
|
|
models.List.privacy_filter(
|
|
request.user, privacy_levels=["public", "followers"]
|
|
)
|
|
.annotate(item_count=Count("listitem", filter=Q(listitem__approved=True)))
|
|
.filter(item_count__gt=0)
|
|
.select_related("user")
|
|
.prefetch_related("listitem_set")
|
|
.order_by("-updated_date")
|
|
.distinct()
|
|
)
|
|
paginated = Paginator(lists, 12)
|
|
data = {
|
|
"lists": paginated.get_page(request.GET.get("page")),
|
|
"list_form": forms.ListForm(),
|
|
"path": "/list",
|
|
}
|
|
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)
|
|
if not form.is_valid():
|
|
return redirect("lists")
|
|
book_list = form.save()
|
|
# list should not have a group if it is not group curated
|
|
if not book_list.curation == "group":
|
|
models.List.objects.filter(id=book_list.id).update(group=None)
|
|
|
|
return redirect(book_list.local_path)
|
|
|
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
class SavedLists(View):
|
|
"""saved book list page"""
|
|
|
|
def get(self, request):
|
|
"""display book lists"""
|
|
# hide lists with no approved books
|
|
lists = request.user.saved_lists.order_by("-updated_date")
|
|
|
|
paginated = Paginator(lists, 12)
|
|
data = {
|
|
"lists": paginated.get_page(request.GET.get("page")),
|
|
"list_form": forms.ListForm(),
|
|
"path": "/list",
|
|
}
|
|
return TemplateResponse(request, "lists/lists.html", data)
|
|
|
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
class UserLists(View):
|
|
"""a user's book list page"""
|
|
|
|
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)
|
|
paginated = Paginator(lists, 12)
|
|
|
|
data = {
|
|
"user": user,
|
|
"is_self": request.user.id == user.id,
|
|
"lists": paginated.get_page(request.GET.get("page")),
|
|
"list_form": forms.ListForm(),
|
|
"path": user.local_path + "/lists",
|
|
}
|
|
return TemplateResponse(request, "user/lists.html", data)
|
|
|
|
|
|
class List(View):
|
|
"""book list page"""
|
|
|
|
def get(self, request, list_id):
|
|
"""display a book list"""
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
book_list.raise_visible_to_user(request.user)
|
|
|
|
if is_api_request(request):
|
|
return ActivitypubResponse(book_list.to_activity(**request.GET))
|
|
|
|
query = request.GET.get("q")
|
|
suggestions = None
|
|
|
|
# sort_by shall be "order" unless a valid alternative is given
|
|
sort_by = request.GET.get("sort_by", "order")
|
|
if sort_by not in ("order", "title", "rating"):
|
|
sort_by = "order"
|
|
|
|
# direction shall be "ascending" unless a valid alternative is given
|
|
direction = request.GET.get("direction", "ascending")
|
|
if direction not in ("ascending", "descending"):
|
|
direction = "ascending"
|
|
|
|
directional_sort_by = {
|
|
"order": "order",
|
|
"title": "book__title",
|
|
"rating": "average_rating",
|
|
}[sort_by]
|
|
if direction == "descending":
|
|
directional_sort_by = "-" + directional_sort_by
|
|
|
|
items = book_list.listitem_set.prefetch_related("user", "book", "book__authors")
|
|
if sort_by == "rating":
|
|
items = items.annotate(
|
|
average_rating=Avg(
|
|
Coalesce("book__review__rating", 0.0),
|
|
output_field=DecimalField(),
|
|
)
|
|
)
|
|
items = items.filter(approved=True).order_by(directional_sort_by)
|
|
|
|
paginated = Paginator(items, PAGE_LENGTH)
|
|
|
|
if query and request.user.is_authenticated:
|
|
# search for books
|
|
suggestions = book_search.search(
|
|
query,
|
|
filters=[~Q(parent_work__editions__in=book_list.books.all())],
|
|
)
|
|
elif request.user.is_authenticated:
|
|
# just suggest whatever books are nearby
|
|
suggestions = request.user.shelfbook_set.filter(
|
|
~Q(book__in=book_list.books.all())
|
|
)
|
|
suggestions = [s.book for s in suggestions[:5]]
|
|
if len(suggestions) < 5:
|
|
suggestions += [
|
|
s.default_edition
|
|
for s in models.Work.objects.filter(
|
|
~Q(editions__in=book_list.books.all()),
|
|
).order_by("-updated_date")
|
|
][: 5 - len(suggestions)]
|
|
|
|
page = paginated.get_page(request.GET.get("page"))
|
|
data = {
|
|
"list": book_list,
|
|
"items": page,
|
|
"page_range": paginated.get_elided_page_range(
|
|
page.number, on_each_side=2, on_ends=1
|
|
),
|
|
"pending_count": book_list.listitem_set.filter(approved=False).count(),
|
|
"suggested_books": suggestions,
|
|
"list_form": forms.ListForm(instance=book_list),
|
|
"query": query or "",
|
|
"sort_form": forms.SortListForm(
|
|
{"direction": direction, "sort_by": sort_by}
|
|
),
|
|
}
|
|
return TemplateResponse(request, "lists/list.html", data)
|
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
def post(self, request, list_id):
|
|
"""edit a list"""
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
book_list.raise_not_editable(request.user)
|
|
|
|
form = forms.ListForm(request.POST, instance=book_list)
|
|
if not form.is_valid():
|
|
return redirect("list", book_list.id)
|
|
book_list = form.save()
|
|
if not book_list.curation == "group":
|
|
models.List.objects.filter(id=book_list.id).update(group=None)
|
|
return redirect(book_list.local_path)
|
|
|
|
|
|
class Curate(View):
|
|
"""approve or discard list suggestsions"""
|
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
def get(self, request, list_id):
|
|
"""display a pending list"""
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
book_list.raise_not_editable(request.user)
|
|
|
|
data = {
|
|
"list": book_list,
|
|
"pending": book_list.listitem_set.filter(approved=False),
|
|
"list_form": forms.ListForm(instance=book_list),
|
|
}
|
|
return TemplateResponse(request, "lists/curate.html", data)
|
|
|
|
@method_decorator(login_required, name="dispatch")
|
|
# pylint: disable=unused-argument
|
|
def post(self, request, list_id):
|
|
"""edit a book_list"""
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
book_list.raise_not_editable(request.user)
|
|
|
|
suggestion = get_object_or_404(models.ListItem, id=request.POST.get("item"))
|
|
approved = request.POST.get("approved") == "true"
|
|
if approved:
|
|
# update the book and set it to be the last in the order of approved books,
|
|
# before any pending books
|
|
suggestion.approved = True
|
|
order_max = (
|
|
book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[
|
|
"order__max"
|
|
]
|
|
or 0
|
|
) + 1
|
|
suggestion.order = order_max
|
|
increment_order_in_reverse(book_list.id, order_max)
|
|
suggestion.save()
|
|
else:
|
|
deleted_order = suggestion.order
|
|
suggestion.delete(broadcast=False)
|
|
normalize_book_list_ordering(book_list.id, start=deleted_order)
|
|
return redirect("list-curate", book_list.id)
|
|
|
|
|
|
@require_POST
|
|
@login_required
|
|
def save_list(request, list_id):
|
|
"""save a list"""
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
request.user.saved_lists.add(book_list)
|
|
return redirect("list", list_id)
|
|
|
|
|
|
@require_POST
|
|
@login_required
|
|
def unsave_list(request, list_id):
|
|
"""unsave a list"""
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
request.user.saved_lists.remove(book_list)
|
|
return redirect("list", list_id)
|
|
|
|
|
|
@require_POST
|
|
@login_required
|
|
def delete_list(request, list_id):
|
|
"""delete a list"""
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
|
|
# only the owner or a moderator can delete a list
|
|
book_list.raise_not_deletable(request.user)
|
|
|
|
book_list.delete()
|
|
return redirect("lists")
|
|
|
|
|
|
@require_POST
|
|
@login_required
|
|
def add_book(request):
|
|
"""put a book on a list"""
|
|
book_list = get_object_or_404(models.List, id=request.POST.get("list"))
|
|
is_group_member = False
|
|
if book_list.curation == "group":
|
|
is_group_member = models.GroupMember.objects.filter(
|
|
group=book_list.group, user=request.user
|
|
).exists()
|
|
|
|
book_list.raise_visible_to_user(request.user)
|
|
|
|
book = get_object_or_404(models.Edition, id=request.POST.get("book"))
|
|
# do you have permission to add to the list?
|
|
try:
|
|
if (
|
|
request.user == book_list.user
|
|
or is_group_member
|
|
or book_list.curation == "open"
|
|
):
|
|
# 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)
|
|
models.ListItem.objects.create(
|
|
book=book,
|
|
book_list=book_list,
|
|
user=request.user,
|
|
order=order_max + 1,
|
|
)
|
|
elif book_list.curation == "curated":
|
|
# make a pending entry at the end of the list
|
|
order_max = (
|
|
book_list.listitem_set.aggregate(Max("order"))["order__max"]
|
|
) or 0
|
|
models.ListItem.objects.create(
|
|
approved=False,
|
|
book=book,
|
|
book_list=book_list,
|
|
user=request.user,
|
|
order=order_max + 1,
|
|
)
|
|
else:
|
|
# you can't add to this list, what were you THINKING
|
|
return HttpResponseBadRequest()
|
|
except IntegrityError:
|
|
# if the book is already on the list, don't flip out
|
|
pass
|
|
|
|
path = reverse("list", args=[book_list.id])
|
|
params = request.GET.copy()
|
|
params["updated"] = True
|
|
return redirect(f"{path}?{urlencode(params)}")
|
|
|
|
|
|
@require_POST
|
|
@login_required
|
|
def remove_book(request, list_id):
|
|
"""remove a book from a list"""
|
|
|
|
book_list = get_object_or_404(models.List, id=list_id)
|
|
item = get_object_or_404(models.ListItem, id=request.POST.get("item"))
|
|
|
|
item.raise_not_deletable(request.user)
|
|
|
|
with transaction.atomic():
|
|
deleted_order = item.order
|
|
item.delete()
|
|
normalize_book_list_ordering(book_list.id, start=deleted_order)
|
|
|
|
return redirect("list", list_id)
|
|
|
|
|
|
@require_POST
|
|
@login_required
|
|
def set_book_position(request, list_item_id):
|
|
"""
|
|
Action for when the list user manually specifies a list position, takes
|
|
special care with the unique ordering per list.
|
|
"""
|
|
list_item = get_object_or_404(models.ListItem, id=list_item_id)
|
|
list_item.book_list.raise_not_editable(request.user)
|
|
try:
|
|
int_position = int(request.POST.get("position"))
|
|
except ValueError:
|
|
return HttpResponseBadRequest("bad value for position. should be an integer")
|
|
|
|
if int_position < 1:
|
|
return HttpResponseBadRequest("position cannot be less than 1")
|
|
|
|
book_list = list_item.book_list
|
|
|
|
# the max position to which a book may be set is the highest order for
|
|
# books which are approved
|
|
order_max = book_list.listitem_set.filter(approved=True).aggregate(Max("order"))[
|
|
"order__max"
|
|
]
|
|
|
|
int_position = min(int_position, order_max)
|
|
|
|
original_order = list_item.order
|
|
if original_order == int_position:
|
|
# no change
|
|
return HttpResponse(status=204)
|
|
|
|
with transaction.atomic():
|
|
if original_order > int_position:
|
|
list_item.order = -1
|
|
list_item.save()
|
|
increment_order_in_reverse(book_list.id, int_position, original_order)
|
|
else:
|
|
list_item.order = -1
|
|
list_item.save()
|
|
decrement_order(book_list.id, original_order, int_position)
|
|
|
|
list_item.order = int_position
|
|
list_item.save()
|
|
|
|
return redirect("list", book_list.id)
|
|
|
|
|
|
@transaction.atomic
|
|
def increment_order_in_reverse(
|
|
book_list_id: int, start: int, end: Optional[int] = None
|
|
):
|
|
"""increase the order number for every item in a list"""
|
|
try:
|
|
book_list = models.List.objects.get(id=book_list_id)
|
|
except models.List.DoesNotExist:
|
|
return
|
|
items = book_list.listitem_set.filter(order__gte=start)
|
|
if end is not None:
|
|
items = items.filter(order__lt=end)
|
|
items = items.order_by("-order")
|
|
for item in items:
|
|
item.order += 1
|
|
item.save()
|
|
|
|
|
|
@transaction.atomic
|
|
def decrement_order(book_list_id, start, end):
|
|
"""decrement the order value for every item in a list"""
|
|
try:
|
|
book_list = models.List.objects.get(id=book_list_id)
|
|
except models.List.DoesNotExist:
|
|
return
|
|
items = book_list.listitem_set.filter(order__gt=start, order__lte=end).order_by(
|
|
"order"
|
|
)
|
|
for item in items:
|
|
item.order -= 1
|
|
item.save()
|
|
|
|
|
|
@transaction.atomic
|
|
def normalize_book_list_ordering(book_list_id, start=0, add_offset=0):
|
|
"""gives each book in a list the proper sequential order number"""
|
|
try:
|
|
book_list = models.List.objects.get(id=book_list_id)
|
|
except models.List.DoesNotExist:
|
|
return
|
|
items = book_list.listitem_set.filter(order__gt=start).order_by("order")
|
|
for i, item in enumerate(items, start):
|
|
effective_order = i + add_offset
|
|
if item.order != effective_order:
|
|
item.order = effective_order
|
|
item.save()
|