""" 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, 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 bookwyrm.views.helpers import is_api_request # pylint: disable=no-self-use 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")) embed_key = str(book_list.embed_key.hex) 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()}" 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} ), "embed_url": embed_url, } 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": book_list.group = None book_list.save(broadcast=False) return redirect(book_list.local_path) @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 @transaction.atomic def add_book(request): """put a book on a list""" book_list = get_object_or_404(models.List, id=request.POST.get("book_list")) # make sure the user is allowed to submit to this list book_list.raise_not_submittable(request.user) form = forms.ListItemForm(request.POST) if not form.is_valid(): # this shouldn't happen, there aren't validated fields raise Exception(form.errors) item = form.save(commit=False) if 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 item.approved = False else: # 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 try: item.save() 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()