Merge pull request #692 from mouse-reeve/create-book

Create book
This commit is contained in:
Mouse Reeve 2021-03-12 10:26:15 -08:00 committed by GitHub
commit f220290a2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 336 additions and 44 deletions

View file

@ -143,7 +143,7 @@ class EditionForm(CustomForm):
"created_date", "created_date",
"updated_date", "updated_date",
"edition_rank", "edition_rank",
"authors", # TODO "authors",
"parent_work", "parent_work",
"shelves", "shelves",
"subjects", # TODO "subjects", # TODO

View file

@ -2,18 +2,24 @@
{% load i18n %} {% load i18n %}
{% load humanize %} {% 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 %} {% block content %}
<header class="block"> <header class="block">
<h1 class="title level-left"> <h1 class="title level-left">
Edit "{{ book.title }}" {% if book %}
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
{% else %}
{% trans "Add Book" %}
{% endif %}
</h1> </h1>
{% if book %}
<div> <div>
<p>{% trans "Added:" %} {{ book.created_date | naturaltime }}</p> <p>{% trans "Added:" %} {{ book.created_date | naturaltime }}</p>
<p>{% trans "Updated:" %} {{ book.updated_date | naturaltime }}</p> <p>{% trans "Updated:" %} {{ book.updated_date | naturaltime }}</p>
<p>{% trans "Last edited by:" %} <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p> <p>{% trans "Last edited by:" %} <a href="{{ book.last_edited_by.remote_id }}">{{ book.last_edited_by.display_name }}</a></p>
</div> </div>
{% endif %}
</header> </header>
{% if form.non_field_errors %} {% if form.non_field_errors %}
@ -22,40 +28,111 @@
</div> </div>
{% endif %} {% endif %}
<form class="block" name="edit-book" action="{{ book.local_path }}/edit" method="post" enctype="multipart/form-data"> {% if book %}
<form class="block" name="edit-book" action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}" method="post" enctype="multipart/form-data">
{% else %}
<form class="block" name="create-book" action="/create-book{% if confirm_mode %}/confirm{% endif %}" method="post" enctype="multipart/form-data">
{% endif %}
{% csrf_token %} {% csrf_token %}
{% if confirm_mode %}
<div class="box">
<h2 class="title is-4">{% trans "Confirm Book Info" %}</h2>
<div class="columns">
{% if author_matches %}
<div class="column is-half">
{% for author in author_matches %}
<fieldset class="mb-4">
<legend class="title is-5 mb-1">{% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}</legend>
{% with forloop.counter as counter %}
{% for match in author.matches %}
<label><input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required> {{ match.name }}</label>
<p class="help">
<a href="{{ author.local_path }}" target="_blank">{% blocktrans with book_title=match.book_set.first.title %}Author of <em>{{ book_title }}</em>{% endblocktrans %}</a>
</p>
{% endfor %}
<label><input type="radio" name="author_match-{{ counter }}" value="0" required> {% trans "This is a new author" %}</label>
{% endwith %}
</fieldset>
{% endfor %}
</div>
{% else %}
<p class="column is-half">{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}</p>
{% endif %}
{% if not book %}
<div class="column is-half">
<fieldset>
<legend class="title is-5 mb-1">{% trans "Is this an edition of an existing work?" %}</legend>
{% for match in book_matches %}
<label class="label"><input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}</label>
{% endfor %}
<label><input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}</label>
</fieldset>
</div>
{% endif %}
</div>
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
<a href="#" class="button" data-back>
<span>{% trans "Back" %}</span>
</a>
</div>
<hr class="block">
{% endif %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}"> <input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<h2 class="title is-4">{% trans "Metadata" %}</h2> <section class="block">
<p class="fields is-grouped"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p> <h2 class="title is-4">{% trans "Metadata" %}</h2>
{% for error in form.title.errors %} <p class="fields is-grouped"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.title.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
<p class="fields is-grouped"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p> {% endfor %}
{% for error in form.subtitle.errors %} <p class="fields is-grouped"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p>
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.subtitle.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
<p class="fields is-grouped"><label class="label" for="id_description">{% trans "Description:" %}</label> {{ form.description }} </p> {% endfor %}
{% for error in form.description.errors %} <p class="fields is-grouped"><label class="label" for="id_description">{% trans "Description:" %}</label> {{ form.description }} </p>
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.description.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
<p class="fields is-grouped"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p> {% endfor %}
{% for error in form.series.errors %} <p class="fields is-grouped"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p>
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.series.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
<p class="fields is-grouped"><label class="label" for="id_series_number">{% trans "Series number:" %}</label> {{ form.series_number }} </p> {% endfor %}
{% for error in form.series_number.errors %} <p class="fields is-grouped"><label class="label" for="id_series_number">{% trans "Series number:" %}</label> {{ form.series_number }} </p>
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.series_number.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
<p class="fields is-grouped"><label class="label" for="id_first_published_date">{% trans "First published date:" %}</label> {{ form.first_published_date }} </p> {% endfor %}
{% for error in form.first_published_date.errors %} <p class="fields is-grouped"><label class="label" for="id_first_published_date">{% trans "First published date:" %}</label> {{ form.first_published_date }} </p>
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.first_published_date.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
<p class="fields is-grouped"><label class="label" for="id_published_date">{% trans "Published date:" %}</label> {{ form.published_date }} </p> {% endfor %}
{% for error in form.published_date.errors %} <p class="fields is-grouped"><label class="label" for="id_published_date">{% trans "Published date:" %}</label> {{ form.published_date }} </p>
<p class="help is-danger">{{ error | escape }}</p> {% for error in form.published_date.errors %}
{% endfor %} <p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</section>
<section class="block">
<h2 class="title is-4">{% trans "Authors" %}</h2>
{% if book.authors.exists %}
<fieldset>
{% for author in book.authors.all %}
<label class="label mb-2">
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %}>
{% blocktrans with name=author.name path=author.local_path %}Remove <a href="{{ path }}">{{ name }}</a>{% endblocktrans %}
</label>
{% endfor %}
</fieldset>
{% endif %}
<label class="label" for="id_add_author">{% trans "Add Authors:" %}</label>
<p class="help">Separate multiple author names with commas.</p>
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'John Doe, Jane Smith' %}" value="{{ add_author }}" {% if confirm_mode %}readonly{% endif %}>
</section>
</div> </div>
<div class="column"> <div class="column">
@ -116,10 +193,12 @@
</div> </div>
</div> </div>
{% if not confirm_mode %}
<div class="block"> <div class="block">
<button class="button is-primary" type="submit">{% trans "Save" %}</button> <button class="button is-primary" type="submit">{% trans "Save" %}</button>
<a class="button" href="/book/{{ book.id }}">{% trans "Cancel" %}</a> <a class="button" href="/book/{{ book.id }}">{% trans "Cancel" %}</a>
</div> </div>
{% endif %}
</form> </form>
{% endblock %} {% endblock %}

View file

@ -68,6 +68,10 @@
{% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %} {% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %}
{% endif %} {% endif %}
</div> </div>
<div class="block">
<a href="/create-book">Manually add book</a>
</div>
{% endif %} {% endif %}
</div> </div>
<div class="column"> <div class="column">

View file

@ -84,6 +84,108 @@ class BookViews(TestCase):
self.book.refresh_from_db() self.book.refresh_from_db()
self.assertEqual(self.book.title, "New Title") 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): def test_switch_edition(self):
""" updates user's relationships to a book """ """ updates user's relationships to a book """
work = models.Work.objects.create(title="test work") work = models.Work.objects.create(title="test work")

View file

@ -127,6 +127,9 @@ urlpatterns = [
# books # books
re_path(r"%s(.json)?/?$" % book_path, views.Book.as_view()), 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/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"%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),
re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description), re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description),

View file

@ -2,7 +2,7 @@
from .authentication import Login, Register, Logout from .authentication import Login, Register, Logout
from .author import Author, EditAuthor from .author import Author, EditAuthor
from .block import Block, unblock 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 .books import upload_cover, add_description, switch_edition, resolve_book
from .error import not_found_page, server_error_page from .error import not_found_page, server_error_page
from .federation import Federation from .federation import Federation

View file

@ -1,6 +1,7 @@
""" the good stuff! the books! """ """ the good stuff! the books! """
from django.core.paginator import Paginator
from django.contrib.auth.decorators import login_required, permission_required 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 import transaction
from django.db.models import Avg, Q from django.db.models import Avg, Q
from django.http import HttpResponseNotFound from django.http import HttpResponseNotFound
@ -106,23 +107,126 @@ class Book(View):
class EditBook(View): class EditBook(View):
""" edit a book """ """ edit a book """
def get(self, request, book_id): def get(self, request, book_id=None):
""" info about a book """ """ info about a book """
book = get_edition(book_id) book = None
if not book.description: if book_id:
book.description = book.parent_work.description book = get_edition(book_id)
if not book.description:
book.description = book.parent_work.description
data = {"book": book, "form": forms.EditionForm(instance=book)} data = {"book": book, "form": forms.EditionForm(instance=book)}
return TemplateResponse(request, "edit_book.html", data) return TemplateResponse(request, "edit_book.html", data)
def post(self, request, book_id): def post(self, request, book_id=None):
""" edit a book cool """ """ 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) form = forms.EditionForm(request.POST, request.FILES, instance=book)
data = {"book": book, "form": form}
if not form.is_valid(): if not form.is_valid():
data = {"book": book, "form": form}
return TemplateResponse(request, "edit_book.html", data) 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() 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) return redirect("/book/%s" % book.id)

2
bw-dev
View file

@ -36,7 +36,7 @@ function initdb {
} }
function makeitblack { function makeitblack {
runweb black celerywyrm bookwyrm docker-compose run --rm web black celerywyrm bookwyrm
} }
CMD=$1 CMD=$1