diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index 380e701fa..be6c7df81 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -143,7 +143,7 @@ class EditionForm(CustomForm): "created_date", "updated_date", "edition_rank", - "authors", # TODO + "authors", "parent_work", "shelves", "subjects", # TODO diff --git a/bookwyrm/templates/edit_book.html b/bookwyrm/templates/edit_book.html index 7374e6a00..7c660e9c1 100644 --- a/bookwyrm/templates/edit_book.html +++ b/bookwyrm/templates/edit_book.html @@ -2,18 +2,24 @@ {% load i18n %} {% load humanize %} -{% block title %}{% trans "Edit Book" %}{% endblock %} +{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %} {% block content %}

- Edit "{{ book.title }}" + {% if book %} + {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %} + {% else %} + {% trans "Add Book" %} + {% endif %}

+ {% if book %}

{% trans "Added:" %} {{ book.created_date | naturaltime }}

{% trans "Updated:" %} {{ book.updated_date | naturaltime }}

{% trans "Last edited by:" %} {{ book.last_edited_by.display_name }}

+ {% endif %}
{% if form.non_field_errors %} @@ -22,40 +28,111 @@ {% endif %} -
+{% if book %} + +{% else %} + +{% endif %} + {% csrf_token %} + {% if confirm_mode %} +
+

{% trans "Confirm Book Info" %}

+
+ {% if author_matches %} +
+ {% for author in author_matches %} +
+ {% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %} + {% with forloop.counter as counter %} + {% for match in author.matches %} + +

+ {% blocktrans with book_title=match.book_set.first.title %}Author of {{ book_title }}{% endblocktrans %} +

+ {% endfor %} + + {% endwith %} +
+ {% endfor %} +
+ {% else %} +

{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}

+ {% endif %} + + {% if not book %} +
+
+ {% trans "Is this an edition of an existing work?" %} + {% for match in book_matches %} + + {% endfor %} + +
+
+ {% endif %} +
+ + + + {% trans "Back" %} + +
+ +
+ {% endif %} +
-

{% trans "Metadata" %}

-

{{ form.title }}

- {% for error in form.title.errors %} -

{{ error | escape }}

- {% endfor %} -

{{ form.subtitle }}

- {% for error in form.subtitle.errors %} -

{{ error | escape }}

- {% endfor %} -

{{ form.description }}

- {% for error in form.description.errors %} -

{{ error | escape }}

- {% endfor %} -

{{ form.series }}

- {% for error in form.series.errors %} -

{{ error | escape }}

- {% endfor %} -

{{ form.series_number }}

- {% for error in form.series_number.errors %} -

{{ error | escape }}

- {% endfor %} -

{{ form.first_published_date }}

- {% for error in form.first_published_date.errors %} -

{{ error | escape }}

- {% endfor %} -

{{ form.published_date }}

- {% for error in form.published_date.errors %} -

{{ error | escape }}

- {% endfor %} +
+

{% trans "Metadata" %}

+

{{ form.title }}

+ {% for error in form.title.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.subtitle }}

+ {% for error in form.subtitle.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.description }}

+ {% for error in form.description.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.series }}

+ {% for error in form.series.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.series_number }}

+ {% for error in form.series_number.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.first_published_date }}

+ {% for error in form.first_published_date.errors %} +

{{ error | escape }}

+ {% endfor %} +

{{ form.published_date }}

+ {% for error in form.published_date.errors %} +

{{ error | escape }}

+ {% endfor %} +
+ +
+

{% trans "Authors" %}

+ {% if book.authors.exists %} +
+ {% for author in book.authors.all %} + + {% endfor %} +
+ {% endif %} + +

Separate multiple author names with commas.

+ +
@@ -116,10 +193,12 @@
+ {% if not confirm_mode %}
{% trans "Cancel" %}
+ {% endif %}
{% endblock %} diff --git a/bookwyrm/templates/search_results.html b/bookwyrm/templates/search_results.html index 4e8481f09..13497df83 100644 --- a/bookwyrm/templates/search_results.html +++ b/bookwyrm/templates/search_results.html @@ -68,6 +68,10 @@ {% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %} {% endif %} + +
+ Manually add book +
{% endif %}
diff --git a/bookwyrm/tests/views/test_book.py b/bookwyrm/tests/views/test_book.py index eb8c89b98..1549bdc64 100644 --- a/bookwyrm/tests/views/test_book.py +++ b/bookwyrm/tests/views/test_book.py @@ -84,6 +84,108 @@ class BookViews(TestCase): self.book.refresh_from_db() self.assertEqual(self.book.title, "New Title") + def test_edit_book_add_author(self): + """ lets a user edit a book with new authors """ + view = views.EditBook.as_view() + self.local_user.groups.add(self.group) + form = forms.EditionForm(instance=self.book) + form.data["title"] = "New Title" + form.data["last_edited_by"] = self.local_user.id + form.data["add_author"] = "Sappho" + request = self.factory.post("", form.data) + request.user = self.local_user + + result = view(request, self.book.id) + result.render() + + # the changes haven't been saved yet + self.book.refresh_from_db() + self.assertEqual(self.book.title, "Example Edition") + + def test_edit_book_add_new_author_confirm(self): + """ lets a user edit a book confirmed with new authors """ + view = views.ConfirmEditBook.as_view() + self.local_user.groups.add(self.group) + form = forms.EditionForm(instance=self.book) + form.data["title"] = "New Title" + form.data["last_edited_by"] = self.local_user.id + form.data["add_author"] = "Sappho" + request = self.factory.post("", form.data) + request.user = self.local_user + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + view(request, self.book.id) + + self.book.refresh_from_db() + self.assertEqual(self.book.title, "New Title") + self.assertEqual(self.book.authors.first().name, "Sappho") + + def test_edit_book_remove_author(self): + """ remove an author from a book """ + author = models.Author.objects.create(name="Sappho") + self.book.authors.add(author) + form = forms.EditionForm(instance=self.book) + view = views.EditBook.as_view() + self.local_user.groups.add(self.group) + form = forms.EditionForm(instance=self.book) + form.data["title"] = "New Title" + form.data["last_edited_by"] = self.local_user.id + form.data["remove_authors"] = [author.id] + request = self.factory.post("", form.data) + request.user = self.local_user + + with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): + view(request, self.book.id) + self.book.refresh_from_db() + self.assertEqual(self.book.title, "New Title") + self.assertFalse(self.book.authors.exists()) + + def test_create_book(self): + """ create an entirely new book and work """ + view = views.ConfirmEditBook.as_view() + self.local_user.groups.add(self.group) + form = forms.EditionForm() + form.data["title"] = "New Title" + form.data["last_edited_by"] = self.local_user.id + request = self.factory.post("", form.data) + request.user = self.local_user + + view(request) + book = models.Edition.objects.get(title="New Title") + self.assertEqual(book.parent_work.title, "New Title") + + def test_create_book_existing_work(self): + """ create an entirely new book and work """ + view = views.ConfirmEditBook.as_view() + self.local_user.groups.add(self.group) + form = forms.EditionForm() + form.data["title"] = "New Title" + form.data["parent_work"] = self.work.id + form.data["last_edited_by"] = self.local_user.id + request = self.factory.post("", form.data) + request.user = self.local_user + + view(request) + book = models.Edition.objects.get(title="New Title") + self.assertEqual(book.parent_work, self.work) + + def test_create_book_with_author(self): + """ create an entirely new book and work """ + view = views.ConfirmEditBook.as_view() + self.local_user.groups.add(self.group) + form = forms.EditionForm() + form.data["title"] = "New Title" + form.data["add_author"] = "Sappho" + form.data["last_edited_by"] = self.local_user.id + request = self.factory.post("", form.data) + request.user = self.local_user + + view(request) + book = models.Edition.objects.get(title="New Title") + self.assertEqual(book.parent_work.title, "New Title") + self.assertEqual(book.authors.first().name, "Sappho") + self.assertEqual(book.authors.first(), book.parent_work.authors.first()) + def test_switch_edition(self): """ updates user's relationships to a book """ work = models.Work.objects.create(title="test work") diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 844f89937..34f78bf5c 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -127,6 +127,9 @@ urlpatterns = [ # books re_path(r"%s(.json)?/?$" % book_path, views.Book.as_view()), re_path(r"%s/edit/?$" % book_path, views.EditBook.as_view()), + re_path(r"%s/confirm/?$" % book_path, views.ConfirmEditBook.as_view()), + 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\d+)/?$", views.upload_cover), re_path(r"^add-description/(?P\d+)/?$", views.add_description), diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py index 48da8ec1a..d15a3024d 100644 --- a/bookwyrm/views/__init__.py +++ b/bookwyrm/views/__init__.py @@ -2,7 +2,7 @@ from .authentication import Login, Register, Logout from .author import Author, EditAuthor from .block import Block, unblock -from .books import Book, EditBook, Editions +from .books import Book, EditBook, ConfirmEditBook, Editions from .books import upload_cover, add_description, switch_edition, resolve_book from .error import not_found_page, server_error_page from .federation import Federation diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index 46df04830..0a11b87cf 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -1,6 +1,7 @@ """ the good stuff! the books! """ -from django.core.paginator import Paginator from django.contrib.auth.decorators import login_required, permission_required +from django.contrib.postgres.search import SearchRank, SearchVector +from django.core.paginator import Paginator from django.db import transaction from django.db.models import Avg, Q from django.http import HttpResponseNotFound @@ -106,23 +107,126 @@ class Book(View): class EditBook(View): """ edit a book """ - def get(self, request, book_id): + def get(self, request, book_id=None): """ info about a book """ - book = get_edition(book_id) - if not book.description: - book.description = book.parent_work.description + book = None + if book_id: + book = get_edition(book_id) + 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) - def post(self, request, book_id): + def post(self, request, book_id=None): """ edit a book cool """ - book = get_object_or_404(models.Edition, id=book_id) - + # returns None if no match is found + book = models.Edition.objects.filter(id=book_id).first() form = forms.EditionForm(request.POST, request.FILES, instance=book) + + data = {"book": book, "form": form} if not form.is_valid(): - data = {"book": book, "form": form} return TemplateResponse(request, "edit_book.html", data) + + add_author = request.POST.get("add_author") + # we're adding an author through a free text field + if add_author: + data["add_author"] = add_author + data["author_matches"] = [] + for author in add_author.split(","): + if not author: + continue + # check for existing authors + vector = SearchVector("name", weight="A") + SearchVector( + "aliases", weight="B" + ) + + data["author_matches"].append( + { + "name": author.strip(), + "matches": ( + models.Author.objects.annotate(search=vector) + .annotate(rank=SearchRank(vector, add_author)) + .filter(rank__gt=0.4) + .order_by("-rank")[:5] + ), + } + ) + + # we're creating a new book + if not book: + # check if this is an edition of an existing work + author_text = book.author_text if book else add_author + data["book_matches"] = connector_manager.local_search( + "%s %s" % (form.cleaned_data.get("title"), author_text), + min_confidence=0.5, + raw=True, + )[:5] + + # either of the above cases requires additional confirmation + if add_author or not book: + # creting a book or adding an author to a book needs another step + 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) + + remove_authors = request.POST.getlist("remove_authors") + for author_id in remove_authors: + book.authors.remove(author_id) + book = form.save() + return redirect("/book/%s" % book.id) + + +@method_decorator(login_required, name="dispatch") +@method_decorator( + permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch" +) +class ConfirmEditBook(View): + """ confirm edits to a book """ + + def post(self, request, book_id=None): + """ edit a book cool """ + # returns None if no match is found + book = models.Edition.objects.filter(id=book_id).first() + form = forms.EditionForm(request.POST, request.FILES, instance=book) + + data = {"book": book, "form": form} + if not form.is_valid(): + return TemplateResponse(request, "edit_book.html", data) + + with transaction.atomic(): + # save book + book = form.save() + + # get or create author as needed + if request.POST.get("add_author"): + for (i, author) in enumerate(request.POST.get("add_author").split(",")): + if not author: + continue + match = request.POST.get("author_match-%d" % i) + if match and match != "0": + author = get_object_or_404( + models.Author, id=request.POST["author_match-%d" % i] + ) + else: + author = models.Author.objects.create(name=author.strip()) + book.authors.add(author) + + # create work, if needed + if not book_id: + work_match = request.POST.get("parent_work") + if work_match and work_match != "0": + work = get_object_or_404(models.Work, id=work_match) + else: + work = models.Work.objects.create(title=form.cleaned_data["title"]) + work.authors.set(book.authors.all()) + book.parent_work = work + # we don't tell the world when creating a book + book.save(broadcast=False) + + for author_id in request.POST.getlist("remove_authors"): + book.authors.remove(author_id) return redirect("/book/%s" % book.id) diff --git a/bw-dev b/bw-dev index 74c42fbbc..712b80287 100755 --- a/bw-dev +++ b/bw-dev @@ -36,7 +36,7 @@ function initdb { } function makeitblack { - runweb black celerywyrm bookwyrm + docker-compose run --rm web black celerywyrm bookwyrm } CMD=$1