Repository: bookwyrm-social/bookwyrm

Issue:
[Feature] Directly Input Classifications/Identifiers, and search Editions
Requesting the ability to directly query classifications and identifiers such as ISBN 10/13, numbers, OpenLibrary (or other) entry identifiers, etc.
It already seems like this might be possible, however when searching, the results don't show the identifier you searched, just the name and author so it's not always clear.

Furthermore, I suggest being able to search when viewing editions. It's pretty much impossible to find editions when there's like 300 editions for a book. Especially because some have similar covers or may be old books with lots of translations.  


Discussion:
Search already matches identifiers directly - it only defaults to a title/author search if there isn't an identifier match. Searching within editions is a good idea; at the moment, you can filter by a couple attributes.

Resolving patch:
diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py
--- a/bookwyrm/views/__init__.py
+++ b/bookwyrm/views/__init__.py
@@ -4,11 +4,12 @@
 from .authentication import ConfirmEmail, ConfirmEmailCode, resend_link
 from .author import Author, EditAuthor
 from .block import Block, unblock
-from .books import Book, EditBook, ConfirmEditBook, Editions
-from .books import upload_cover, add_description, switch_edition, resolve_book
+from .books import Book, EditBook, ConfirmEditBook
+from .books import upload_cover, add_description, resolve_book
 from .directory import Directory
 from .discover import Discover
 from .edit_user import EditUser, DeleteUser
+from .editions import Editions, switch_edition
 from .federation import Federation, FederatedServer
 from .federation import AddFederatedServer, ImportServerBlocklist
 from .federation import block_server, unblock_server
diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py
--- a/bookwyrm/views/books.py
+++ b/bookwyrm/views/books.py
@@ -24,7 +24,7 @@
 from .helpers import is_api_request, get_edition, privacy_filter
 
 
-# pylint: disable= no-self-use
+# pylint: disable=no-self-use
 class Book(View):
     """a book! this is the stuff"""
 
@@ -270,37 +270,6 @@ def post(self, request, book_id=None):
         return redirect("/book/%s" % book.id)
 
 
-class Editions(View):
-    """list of editions"""
-
-    def get(self, request, book_id):
-        """list of editions of a book"""
-        work = get_object_or_404(models.Work, id=book_id)
-
-        if is_api_request(request):
-            return ActivitypubResponse(work.to_edition_list(**request.GET))
-        filters = {}
-
-        if request.GET.get("language"):
-            filters["languages__contains"] = [request.GET.get("language")]
-        if request.GET.get("format"):
-            filters["physical_format__iexact"] = request.GET.get("format")
-
-        editions = work.editions.order_by("-edition_rank")
-        languages = set(sum([e.languages for e in editions], []))
-
-        paginated = Paginator(editions.filter(**filters), PAGE_LENGTH)
-        data = {
-            "editions": paginated.get_page(request.GET.get("page")),
-            "work": work,
-            "languages": languages,
-            "formats": set(
-                e.physical_format.lower() for e in editions if e.physical_format
-            ),
-        }
-        return TemplateResponse(request, "book/editions.html", data)
-
-
 @login_required
 @require_POST
 def upload_cover(request, book_id):
@@ -363,33 +332,3 @@ def resolve_book(request):
     book = connector.get_or_create_book(remote_id)
 
     return redirect("book", book.id)
-
-
-@login_required
-@require_POST
-@transaction.atomic
-def switch_edition(request):
-    """switch your copy of a book to a different edition"""
-    edition_id = request.POST.get("edition")
-    new_edition = get_object_or_404(models.Edition, id=edition_id)
-    shelfbooks = models.ShelfBook.objects.filter(
-        book__parent_work=new_edition.parent_work, shelf__user=request.user
-    )
-    for shelfbook in shelfbooks.all():
-        with transaction.atomic():
-            models.ShelfBook.objects.create(
-                created_date=shelfbook.created_date,
-                user=shelfbook.user,
-                shelf=shelfbook.shelf,
-                book=new_edition,
-            )
-            shelfbook.delete()
-
-    readthroughs = models.ReadThrough.objects.filter(
-        book__parent_work=new_edition.parent_work, user=request.user
-    )
-    for readthrough in readthroughs.all():
-        readthrough.book = new_edition
-        readthrough.save()
-
-    return redirect("/book/%d" % new_edition.id)
diff --git a/bookwyrm/views/editions.py b/bookwyrm/views/editions.py
new file mode 100644
--- /dev/null
+++ b/bookwyrm/views/editions.py
@@ -0,0 +1,99 @@
+""" the good stuff! the books! """
+from functools import reduce
+import operator
+
+from django.contrib.auth.decorators import login_required
+from django.core.paginator import Paginator
+from django.db import transaction
+from django.db.models import Q
+from django.shortcuts import get_object_or_404, redirect
+from django.template.response import TemplateResponse
+from django.views import View
+from django.views.decorators.http import require_POST
+
+from bookwyrm import models
+from bookwyrm.activitypub import ActivitypubResponse
+from bookwyrm.settings import PAGE_LENGTH
+from .helpers import is_api_request
+
+
+# pylint: disable=no-self-use
+class Editions(View):
+    """list of editions"""
+
+    def get(self, request, book_id):
+        """list of editions of a book"""
+        work = get_object_or_404(models.Work, id=book_id)
+
+        if is_api_request(request):
+            return ActivitypubResponse(work.to_edition_list(**request.GET))
+        filters = {}
+
+        if request.GET.get("language"):
+            filters["languages__contains"] = [request.GET.get("language")]
+        if request.GET.get("format"):
+            filters["physical_format__iexact"] = request.GET.get("format")
+
+        editions = work.editions.order_by("-edition_rank")
+        languages = set(sum(editions.values_list("languages", flat=True), []))
+
+        editions = editions.filter(**filters)
+
+        query = request.GET.get("q")
+        if query:
+            searchable_array_fields = ["languages", "publishers"]
+            searchable_fields = [
+                "title",
+                "physical_format",
+                "isbn_10",
+                "isbn_13",
+                "oclc_number",
+                "asin",
+            ]
+            search_filter_entries = [
+                {f"{f}__icontains": query} for f in searchable_fields
+            ] + [{f"{f}__iexact": query} for f in searchable_array_fields]
+            editions = editions.filter(
+                reduce(operator.or_, (Q(**f) for f in search_filter_entries))
+            )
+
+        paginated = Paginator(editions, PAGE_LENGTH)
+        data = {
+            "editions": paginated.get_page(request.GET.get("page")),
+            "work": work,
+            "languages": languages,
+            "formats": set(
+                e.physical_format.lower() for e in editions if e.physical_format
+            ),
+        }
+        return TemplateResponse(request, "book/editions.html", data)
+
+
+@login_required
+@require_POST
+@transaction.atomic
+def switch_edition(request):
+    """switch your copy of a book to a different edition"""
+    edition_id = request.POST.get("edition")
+    new_edition = get_object_or_404(models.Edition, id=edition_id)
+    shelfbooks = models.ShelfBook.objects.filter(
+        book__parent_work=new_edition.parent_work, shelf__user=request.user
+    )
+    for shelfbook in shelfbooks.all():
+        with transaction.atomic():
+            models.ShelfBook.objects.create(
+                created_date=shelfbook.created_date,
+                user=shelfbook.user,
+                shelf=shelfbook.shelf,
+                book=new_edition,
+            )
+            shelfbook.delete()
+
+    readthroughs = models.ReadThrough.objects.filter(
+        book__parent_work=new_edition.parent_work, user=request.user
+    )
+    for readthrough in readthroughs.all():
+        readthrough.book = new_edition
+        readthrough.save()
+
+    return redirect("/book/%d" % new_edition.id)


Test patch:
diff --git a/bookwyrm/tests/views/test_book.py b/bookwyrm/tests/views/test_book.py
--- a/bookwyrm/tests/views/test_book.py
+++ b/bookwyrm/tests/views/test_book.py
@@ -280,49 +280,6 @@ def test_create_book_with_author(self):
         self.assertEqual(book.authors.first().name, "Sappho")
         self.assertEqual(book.authors.first(), book.parent_work.authors.first())
 
-    @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
-    def test_switch_edition(self, _):
-        """updates user's relationships to a book"""
-        work = models.Work.objects.create(title="test work")
-        edition1 = models.Edition.objects.create(title="first ed", parent_work=work)
-        edition2 = models.Edition.objects.create(title="second ed", parent_work=work)
-        with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
-            shelf = models.Shelf.objects.create(name="Test Shelf", user=self.local_user)
-            models.ShelfBook.objects.create(
-                book=edition1,
-                user=self.local_user,
-                shelf=shelf,
-            )
-        models.ReadThrough.objects.create(user=self.local_user, book=edition1)
-
-        self.assertEqual(models.ShelfBook.objects.get().book, edition1)
-        self.assertEqual(models.ReadThrough.objects.get().book, edition1)
-        request = self.factory.post("", {"edition": edition2.id})
-        request.user = self.local_user
-        with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
-            views.switch_edition(request)
-
-        self.assertEqual(models.ShelfBook.objects.get().book, edition2)
-        self.assertEqual(models.ReadThrough.objects.get().book, edition2)
-
-    def test_editions_page(self):
-        """there are so many views, this just makes sure it LOADS"""
-        view = views.Editions.as_view()
-        request = self.factory.get("")
-        with patch("bookwyrm.views.books.is_api_request") as is_api:
-            is_api.return_value = False
-            result = view(request, self.work.id)
-        self.assertIsInstance(result, TemplateResponse)
-        result.render()
-        self.assertEqual(result.status_code, 200)
-
-        request = self.factory.get("")
-        with patch("bookwyrm.views.books.is_api_request") as is_api:
-            is_api.return_value = True
-            result = view(request, self.work.id)
-        self.assertIsInstance(result, ActivitypubResponse)
-        self.assertEqual(result.status_code, 200)
-
     def test_upload_cover_file(self):
         """add a cover via file upload"""
         self.assertFalse(self.book.cover)
diff --git a/bookwyrm/tests/views/test_editions.py b/bookwyrm/tests/views/test_editions.py
new file mode 100644
--- /dev/null
+++ b/bookwyrm/tests/views/test_editions.py
@@ -0,0 +1,126 @@
+""" test for app action functionality """
+from unittest.mock import patch
+
+from django.template.response import TemplateResponse
+from django.test import TestCase
+from django.test.client import RequestFactory
+
+from bookwyrm import models, views
+from bookwyrm.activitypub import ActivitypubResponse
+
+
+class BookViews(TestCase):
+    """books books books"""
+
+    def setUp(self):
+        """we need basic test data and mocks"""
+        self.factory = RequestFactory()
+        with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"):
+            self.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",
+            )
+        self.work = models.Work.objects.create(title="Test Work")
+        self.book = models.Edition.objects.create(
+            title="Example Edition",
+            remote_id="https://example.com/book/1",
+            parent_work=self.work,
+            physical_format="paperback",
+        )
+
+        models.SiteSettings.objects.create()
+
+    def test_editions_page(self):
+        """there are so many views, this just makes sure it LOADS"""
+        view = views.Editions.as_view()
+        request = self.factory.get("")
+        with patch("bookwyrm.views.editions.is_api_request") as is_api:
+            is_api.return_value = False
+            result = view(request, self.work.id)
+        self.assertIsInstance(result, TemplateResponse)
+        result.render()
+        self.assertEqual(result.status_code, 200)
+        self.assertTrue("paperback" in result.context_data["formats"])
+
+    def test_editions_page_filtered(self):
+        """editions view with filters"""
+        models.Edition.objects.create(
+            title="Fish",
+            physical_format="okay",
+            parent_work=self.work,
+        )
+        view = views.Editions.as_view()
+        request = self.factory.get("")
+        with patch("bookwyrm.views.editions.is_api_request") as is_api:
+            is_api.return_value = False
+            result = view(request, self.work.id)
+        self.assertIsInstance(result, TemplateResponse)
+        result.render()
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(len(result.context_data["editions"].object_list), 2)
+        self.assertEqual(len(result.context_data["formats"]), 2)
+        self.assertTrue("paperback" in result.context_data["formats"])
+        self.assertTrue("okay" in result.context_data["formats"])
+
+        request = self.factory.get("", {"q": "fish"})
+        with patch("bookwyrm.views.editions.is_api_request") as is_api:
+            is_api.return_value = False
+            result = view(request, self.work.id)
+        result.render()
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(len(result.context_data["editions"].object_list), 1)
+
+        request = self.factory.get("", {"q": "okay"})
+        with patch("bookwyrm.views.editions.is_api_request") as is_api:
+            is_api.return_value = False
+            result = view(request, self.work.id)
+        result.render()
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(len(result.context_data["editions"].object_list), 1)
+
+        request = self.factory.get("", {"format": "okay"})
+        with patch("bookwyrm.views.editions.is_api_request") as is_api:
+            is_api.return_value = False
+            result = view(request, self.work.id)
+        result.render()
+        self.assertEqual(result.status_code, 200)
+        self.assertEqual(len(result.context_data["editions"].object_list), 1)
+
+    def test_editions_page_api(self):
+        """there are so many views, this just makes sure it LOADS"""
+        view = views.Editions.as_view()
+        request = self.factory.get("")
+        with patch("bookwyrm.views.editions.is_api_request") as is_api:
+            is_api.return_value = True
+            result = view(request, self.work.id)
+        self.assertIsInstance(result, ActivitypubResponse)
+        self.assertEqual(result.status_code, 200)
+
+    @patch("bookwyrm.suggested_users.rerank_suggestions_task.delay")
+    def test_switch_edition(self, _):
+        """updates user's relationships to a book"""
+        work = models.Work.objects.create(title="test work")
+        edition1 = models.Edition.objects.create(title="first ed", parent_work=work)
+        edition2 = models.Edition.objects.create(title="second ed", parent_work=work)
+        with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
+            shelf = models.Shelf.objects.create(name="Test Shelf", user=self.local_user)
+            models.ShelfBook.objects.create(
+                book=edition1,
+                user=self.local_user,
+                shelf=shelf,
+            )
+        models.ReadThrough.objects.create(user=self.local_user, book=edition1)
+
+        self.assertEqual(models.ShelfBook.objects.get().book, edition1)
+        self.assertEqual(models.ReadThrough.objects.get().book, edition1)
+        request = self.factory.post("", {"edition": edition2.id})
+        request.user = self.local_user
+        with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
+            views.switch_edition(request)
+
+        self.assertEqual(models.ShelfBook.objects.get().book, edition2)
+        self.assertEqual(models.ReadThrough.objects.get().book, edition2)