Repository: bookwyrm-social/bookwyrm

Issue:
Allow fetching cover via a URL
Currently, Bookwyrm accepts a physical file as an upload when specifying a cover, it would be awesome if it could fetch the cover from a given URL.

This is especially handy when the cover is already present on a site, e.g. Google Books, Goodreads, or on the publisher's/author's site.


Discussion:

Resolving patch:
diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py
--- a/bookwyrm/urls.py
+++ b/bookwyrm/urls.py
@@ -152,7 +152,9 @@
     re_path(r"^create-book/?$", views.EditBook.as_view()),
     re_path(r"^create-book/confirm?$", views.ConfirmEditBook.as_view()),
     re_path(r"%s/editions(.json)?/?$" % book_path, views.Editions.as_view()),
-    re_path(r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover),
+    re_path(
+        r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover, name="upload-cover"
+    ),
     re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description),
     re_path(r"^resolve-book/?$", views.resolve_book),
     re_path(r"^switch-edition/?$", views.switch_edition),
diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py
--- a/bookwyrm/views/books.py
+++ b/bookwyrm/views/books.py
@@ -1,6 +1,9 @@
 """ the good stuff! the books! """
+from uuid import uuid4
+
 from django.contrib.auth.decorators import login_required, permission_required
 from django.contrib.postgres.search import SearchRank, SearchVector
+from django.core.files.base import ContentFile
 from django.core.paginator import Paginator
 from django.db import transaction
 from django.db.models import Avg, Q
@@ -14,6 +17,7 @@
 from bookwyrm import forms, models
 from bookwyrm.activitypub import ActivitypubResponse
 from bookwyrm.connectors import connector_manager
+from bookwyrm.connectors.abstract_connector import get_image
 from bookwyrm.settings import PAGE_LENGTH
 from .helpers import is_api_request, get_activity_feed, get_edition
 from .helpers import privacy_filter
@@ -97,7 +101,7 @@ def get(self, request, book_id):
             "readthroughs": readthroughs,
             "path": "/book/%s" % book_id,
         }
-        return TemplateResponse(request, "book.html", data)
+        return TemplateResponse(request, "book/book.html", data)
 
 
 @method_decorator(login_required, name="dispatch")
@@ -115,7 +119,7 @@ def get(self, request, book_id=None):
             if not book.description:
                 book.description = book.parent_work.description
         data = {"book": book, "form": forms.EditionForm(instance=book)}
-        return TemplateResponse(request, "edit_book.html", data)
+        return TemplateResponse(request, "book/edit_book.html", data)
 
     def post(self, request, book_id=None):
         """ edit a book cool """
@@ -125,7 +129,7 @@ def post(self, request, book_id=None):
 
         data = {"book": book, "form": form}
         if not form.is_valid():
-            return TemplateResponse(request, "edit_book.html", data)
+            return TemplateResponse(request, "book/edit_book.html", data)
 
         add_author = request.POST.get("add_author")
         # we're adding an author through a free text field
@@ -169,13 +173,19 @@ def post(self, request, book_id=None):
             data["confirm_mode"] = True
             # this isn't preserved because it isn't part of the form obj
             data["remove_authors"] = request.POST.getlist("remove_authors")
-            return TemplateResponse(request, "edit_book.html", data)
+            return TemplateResponse(request, "book/edit_book.html", data)
 
         remove_authors = request.POST.getlist("remove_authors")
         for author_id in remove_authors:
             book.authors.remove(author_id)
 
-        book = form.save()
+        book = form.save(commit=False)
+        url = request.POST.get("cover-url")
+        if url:
+            image = set_cover_from_url(url)
+            if image:
+                book.cover.save(*image, save=False)
+        book.save()
         return redirect("/book/%s" % book.id)
 
 
@@ -194,7 +204,7 @@ def post(self, request, book_id=None):
 
         data = {"book": book, "form": form}
         if not form.is_valid():
-            return TemplateResponse(request, "edit_book.html", data)
+            return TemplateResponse(request, "book/edit_book.html", data)
 
         with transaction.atomic():
             # save book
@@ -256,18 +266,35 @@ def get(self, request, book_id):
 def upload_cover(request, book_id):
     """ upload a new cover """
     book = get_object_or_404(models.Edition, id=book_id)
+    book.last_edited_by = request.user
+
+    url = request.POST.get("cover-url")
+    if url:
+        image = set_cover_from_url(url)
+        book.cover.save(*image)
+
+        return redirect("/book/%d" % book.id)
 
     form = forms.CoverForm(request.POST, request.FILES, instance=book)
-    if not form.is_valid():
+    if not form.is_valid() or not form.files.get("cover"):
         return redirect("/book/%d" % book.id)
 
-    book.last_edited_by = request.user
     book.cover = form.files["cover"]
     book.save()
 
     return redirect("/book/%s" % book.id)
 
 
+def set_cover_from_url(url):
+    """ load it from a url """
+    image_file = get_image(url)
+    if not image_file:
+        return None
+    image_name = str(uuid4()) + "." + url.split(".")[-1]
+    image_content = ContentFile(image_file.content)
+    return [image_name, image_content]
+
+
 @login_required
 @require_POST
 @permission_required("bookwyrm.edit_book", raise_exception=True)
diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py
--- a/bookwyrm/views/user.py
+++ b/bookwyrm/views/user.py
@@ -173,7 +173,7 @@ def post(self, request):
             # set the name to a hash
             extension = form.files["avatar"].name.split(".")[-1]
             filename = "%s.%s" % (uuid4(), extension)
-            user.avatar.save(filename, image)
+            user.avatar.save(filename, image, save=False)
         user.save()
 
         return redirect(user.local_path)


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
@@ -1,7 +1,14 @@
 """ test for app action functionality """
+from io import BytesIO
+import pathlib
 from unittest.mock import patch
+
+from PIL import Image
+import responses
+
 from django.contrib.auth.models import Group, Permission
 from django.contrib.contenttypes.models import ContentType
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.template.response import TemplateResponse
 from django.test import TestCase
 from django.test.client import RequestFactory
@@ -229,3 +236,59 @@ def test_editions_page(self):
             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)
+        image_file = pathlib.Path(__file__).parent.joinpath(
+            "../../static/images/default_avi.jpg"
+        )
+
+        form = forms.CoverForm(instance=self.book)
+        form.data["cover"] = SimpleUploadedFile(
+            image_file, open(image_file, "rb").read(), content_type="image/jpeg"
+        )
+
+        request = self.factory.post("", form.data)
+        request.user = self.local_user
+
+        with patch(
+            "bookwyrm.models.activitypub_mixin.broadcast_task.delay"
+        ) as delay_mock:
+            views.upload_cover(request, self.book.id)
+            self.assertEqual(delay_mock.call_count, 1)
+
+        self.book.refresh_from_db()
+        self.assertTrue(self.book.cover)
+
+    @responses.activate
+    def test_upload_cover_url(self):
+        """ add a cover via url """
+        self.assertFalse(self.book.cover)
+        image_file = pathlib.Path(__file__).parent.joinpath(
+            "../../static/images/default_avi.jpg"
+        )
+        image = Image.open(image_file)
+        output = BytesIO()
+        image.save(output, format=image.format)
+        responses.add(
+            responses.GET,
+            "http://example.com",
+            body=output.getvalue(),
+            status=200,
+        )
+
+        form = forms.CoverForm(instance=self.book)
+        form.data["cover-url"] = "http://example.com"
+
+        request = self.factory.post("", form.data)
+        request.user = self.local_user
+
+        with patch(
+            "bookwyrm.models.activitypub_mixin.broadcast_task.delay"
+        ) as delay_mock:
+            views.upload_cover(request, self.book.id)
+            self.assertEqual(delay_mock.call_count, 1)
+
+        self.book.refresh_from_db()
+        self.assertTrue(self.book.cover)
diff --git a/bookwyrm/tests/views/test_user.py b/bookwyrm/tests/views/test_user.py
--- a/bookwyrm/tests/views/test_user.py
+++ b/bookwyrm/tests/views/test_user.py
@@ -5,6 +5,7 @@
 
 from django.contrib.auth.models import AnonymousUser
 from django.core.files.base import ContentFile
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.template.response import TemplateResponse
 from django.test import TestCase
 from django.test.client import RequestFactory
@@ -157,28 +158,31 @@ def test_edit_user(self):
         self.assertEqual(self.local_user.email, "wow@email.com")
 
     # idk how to mock the upload form, got tired of triyng to make it work
-    #     def test_edit_user_avatar(self):
-    #         ''' use a form to update a user '''
-    #         view = views.EditUser.as_view()
-    #         form = forms.EditUserForm(instance=self.local_user)
-    #         form.data['name'] = 'New Name'
-    #         form.data['email'] = 'wow@email.com'
-    #         image_file = pathlib.Path(__file__).parent.joinpath(
-    #             '../../static/images/no_cover.jpg')
-    #         image = Image.open(image_file)
-    #         form.files['avatar'] = SimpleUploadedFile(
-    #             image_file, open(image_file), content_type='image/jpeg')
-    #         request = self.factory.post('', form.data, form.files)
-    #         request.user = self.local_user
-
-    #         with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
-    #                 as delay_mock:
-    #             view(request)
-    #             self.assertEqual(delay_mock.call_count, 1)
-    #         self.assertEqual(self.local_user.name, 'New Name')
-    #         self.assertEqual(self.local_user.email, 'wow@email.com')
-    #         self.assertIsNotNone(self.local_user.avatar)
-    #         self.assertEqual(self.local_user.avatar.size, (120, 120))
+    def test_edit_user_avatar(self):
+        """ use a form to update a user """
+        view = views.EditUser.as_view()
+        form = forms.EditUserForm(instance=self.local_user)
+        form.data["name"] = "New Name"
+        form.data["email"] = "wow@email.com"
+        image_file = pathlib.Path(__file__).parent.joinpath(
+            "../../static/images/no_cover.jpg"
+        )
+        form.data["avatar"] = SimpleUploadedFile(
+            image_file, open(image_file, "rb").read(), content_type="image/jpeg"
+        )
+        request = self.factory.post("", form.data)
+        request.user = self.local_user
+
+        with patch(
+            "bookwyrm.models.activitypub_mixin.broadcast_task.delay"
+        ) as delay_mock:
+            view(request)
+            self.assertEqual(delay_mock.call_count, 1)
+        self.assertEqual(self.local_user.name, "New Name")
+        self.assertEqual(self.local_user.email, "wow@email.com")
+        self.assertIsNotNone(self.local_user.avatar)
+        self.assertEqual(self.local_user.avatar.width, 120)
+        self.assertEqual(self.local_user.avatar.height, 120)
 
     def test_crop_avatar(self):
         """ reduce that image size """