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 %}
{% if form.non_field_errors %}
@@ -22,40 +28,111 @@
{% 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 %}
+
+
{% 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