Merge pull request #1973 from bookwyrm-social/add-edition

Create another edition for existing work
This commit is contained in:
Mouse Reeve 2022-03-17 08:51:13 -07:00 committed by GitHub
commit 2047365d31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 270 additions and 102 deletions

View file

@ -85,3 +85,27 @@ class EditionForm(CustomForm):
), ),
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}), "ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
} }
class EditionFromWorkForm(CustomForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# make all fields hidden
for visible in self.visible_fields():
visible.field.widget = forms.HiddenInput()
class Meta:
model = models.Work
fields = [
"title",
"subtitle",
"authors",
"description",
"languages",
"series",
"series_number",
"subjects",
"subject_places",
"cover",
"first_published_date",
]

View file

@ -208,9 +208,17 @@
{% endif %} {% endif %}
{% if book.parent_work.editions.count > 1 %} {% with work=book.parent_work %}
<p>{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}<a href="{{ path }}/editions">{{ count }} editions</a>{% endblocktrans %}</p> <p>
{% endif %} <a href="{{ work.local_path }}/editions">
{% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
{{ count }} edition
{% plural %}
{{ count }} editions
{% endblocktrans %}
</a>
</p>
{% endwith %}
</div> </div>
{# user's relationship to the book #} {# user's relationship to the book #}

View file

@ -3,18 +3,24 @@
{% load humanize %} {% load humanize %}
{% load utilities %} {% load utilities %}
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %} {% block title %}
{% if book.title %}
{% 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">
{% if book %} {% if book.title %}
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %} {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
{% else %} {% else %}
{% trans "Add Book" %} {% trans "Add Book" %}
{% endif %} {% endif %}
</h1> </h1>
{% if book %} {% if book.created_date %}
<dl> <dl>
<dt class="is-pulled-left mr-5 has-text-weight-semibold">{% trans "Added:" %}</dt> <dt class="is-pulled-left mr-5 has-text-weight-semibold">{% trans "Added:" %}</dt>
<dd class="ml-2">{{ book.created_date | naturaltime }}</dd> <dd class="ml-2">{{ book.created_date | naturaltime }}</dd>
@ -33,7 +39,7 @@
<form <form
class="block" class="block"
{% if book %} {% if book.id %}
name="edit-book" name="edit-book"
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}" action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
{% else %} {% else %}
@ -97,7 +103,7 @@
<input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }} <input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}
</label> </label>
{% endfor %} {% endfor %}
<label> <label class="label mt-2">
<input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %} <input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}
</label> </label>
</fieldset> </fieldset>
@ -119,7 +125,7 @@
{% if not confirm_mode %} {% 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>
{% if book %} {% if book.id %}
<a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a> <a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a>
{% else %} {% else %}
<a href="/" class="button" data-back> <a href="/" class="button" data-back>

View file

@ -10,6 +10,8 @@
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}"> <input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<input type="hidden" name="parent_work" value="{% firstof book.parent_work.id form.parent_work %}">
<div class="columns"> <div class="columns">
<div class="column is-half"> <div class="column is-half">
<section class="block"> <section class="block">
@ -175,6 +177,8 @@
</h2> </h2>
<div class="box"> <div class="box">
{% if book.authors.exists %} {% if book.authors.exists %}
{# preserve authors if the book is unsaved #}
<input type="hidden" name="authors" value="{% for author in book.authors %}{{ author.id }},{% endfor %}">
<fieldset> <fieldset>
{% for author in book.authors.all %} {% for author in book.authors.all %}
<div class="is-flex is-justify-content-space-between"> <div class="is-flex is-justify-content-space-between">

View file

@ -46,7 +46,36 @@
{% endfor %} {% endfor %}
</div> </div>
<div> <div class="block">
{% include 'snippets/pagination.html' with page=editions path=request.path %} {% include 'snippets/pagination.html' with page=editions path=request.path %}
</div> </div>
<div class="block has-text-centered help">
<p>
{% trans "Can't find the edition you're looking for?" %}
</p>
<form action="{% url 'create-book-data' %}" method="POST" name="add-edition-form">
{% csrf_token %}
{{ work_form.title }}
{{ work_form.subtitle }}
{{ work_form.authors }}
{{ work_form.description }}
{{ work_form.languages }}
{{ work_form.series }}
{{ work_form.cover }}
{{ work_form.first_published_date }}
{% for subject in work.subjects %}
<input type="hidden" name="subjects" value="{{ subject }}">
{% endfor %}
<input type="hidden" name="parent_work" value="{{ work.id }}">
<div>
<button class="button is-small" type="submit">
{% trans "Add another edition" %}
</button>
</div>
</form>
</div>
{% endblock %} {% endblock %}

View file

@ -60,7 +60,7 @@ class EditBookViews(TestCase):
def test_edit_book_create_page(self): def test_edit_book_create_page(self):
"""there are so many views, this just makes sure it LOADS""" """there are so many views, this just makes sure it LOADS"""
view = views.EditBook.as_view() view = views.CreateBook.as_view()
request = self.factory.get("") request = self.factory.get("")
request.user = self.local_user request.user = self.local_user
request.user.is_superuser = True request.user.is_superuser = True

View file

@ -524,7 +524,10 @@ urlpatterns = [
), ),
re_path(rf"{BOOK_PATH}/edit/?$", views.EditBook.as_view(), name="edit-book"), re_path(rf"{BOOK_PATH}/edit/?$", views.EditBook.as_view(), name="edit-book"),
re_path(rf"{BOOK_PATH}/confirm/?$", views.ConfirmEditBook.as_view()), re_path(rf"{BOOK_PATH}/confirm/?$", views.ConfirmEditBook.as_view()),
re_path(r"^create-book/?$", views.EditBook.as_view(), name="create-book"), re_path(
r"^create-book/data/?$", views.create_book_from_data, name="create-book-data"
),
re_path(r"^create-book/?$", views.CreateBook.as_view(), name="create-book"),
re_path(r"^create-book/confirm/?$", views.ConfirmEditBook.as_view()), re_path(r"^create-book/confirm/?$", views.ConfirmEditBook.as_view()),
re_path(rf"{BOOK_PATH}/editions(.json)?/?$", views.Editions.as_view()), re_path(rf"{BOOK_PATH}/editions(.json)?/?$", views.Editions.as_view()),
re_path( re_path(

View file

@ -39,7 +39,12 @@ from .books.books import (
resolve_book, resolve_book,
) )
from .books.books import update_book_from_remote from .books.books import update_book_from_remote
from .books.edit_book import EditBook, ConfirmEditBook from .books.edit_book import (
EditBook,
ConfirmEditBook,
CreateBook,
create_book_from_data,
)
from .books.editions import Editions, switch_edition from .books.editions import Editions, switch_edition
from .books.links import BookFileLinks, AddFileLink, delete_link from .books.links import BookFileLinks, AddFileLink, delete_link

View file

@ -1,5 +1,5 @@
""" the good stuff! the books! """ """ the good stuff! the books! """
from re import sub from re import sub, findall
from dateutil.parser import parse as dateparse from dateutil.parser import parse as dateparse
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.contrib.postgres.search import SearchRank, SearchVector
@ -9,6 +9,7 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.datastructures import MultiValueDictKeyError from django.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.http import require_POST
from django.views import View from django.views import View
from bookwyrm import book_search, forms, models from bookwyrm import book_search, forms, models
@ -30,41 +31,151 @@ from .books import set_cover_from_url
class EditBook(View): class EditBook(View):
"""edit a book""" """edit a book"""
def get(self, request, book_id=None): def get(self, request, book_id):
"""info about a book""" """info about a book"""
book = None
if book_id:
book = get_edition(book_id) book = get_edition(book_id)
if not book.description: if not book.description:
book.description = book.parent_work.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, "book/edit/edit_book.html", data) return TemplateResponse(request, "book/edit/edit_book.html", data)
# pylint: disable=too-many-locals def post(self, request, book_id):
def post(self, request, book_id=None):
"""edit a book cool""" """edit a book cool"""
# returns None if no match is found book = get_object_or_404(models.Edition, id=book_id)
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} data = {"book": book, "form": form}
if not form.is_valid(): if not form.is_valid():
return TemplateResponse(request, "book/edit/edit_book.html", data) return TemplateResponse(request, "book/edit/edit_book.html", data)
# filter out empty author fields data = add_authors(request, data)
# either of the above cases requires additional confirmation
if data.get("add_author"):
data = copy_form(data)
return TemplateResponse(request, "book/edit/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(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(f"/book/{book.id}")
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
)
class CreateBook(View):
"""brand new book"""
def get(self, request):
"""info about a book"""
data = {"form": forms.EditionForm()}
return TemplateResponse(request, "book/edit/edit_book.html", data)
# pylint: disable=too-many-locals
def post(self, request):
"""create a new book"""
# returns None if no match is found
form = forms.EditionForm(request.POST, request.FILES)
data = {"form": form}
# collect data provided by the work or import item
parent_work_id = request.POST.get("parent_work")
authors = None
if request.POST.get("authors"):
author_ids = findall(r"\d+", request.POST["authors"])
authors = models.Author.objects.filter(id__in=author_ids)
# fake book in case we need to keep editing
if parent_work_id:
data["book"] = {
"parent_work": {"id": parent_work_id},
"authors": authors,
}
if not form.is_valid():
return TemplateResponse(request, "book/edit/edit_book.html", data)
data = add_authors(request, data)
# check if this is an edition of an existing work
author_text = ", ".join(data.get("add_author", []))
data["book_matches"] = book_search.search(
f'{form.cleaned_data.get("title")} {author_text}',
min_confidence=0.1,
)[:5]
# go to confirm mode
if not parent_work_id or data.get("add_author"):
data = copy_form(data)
return TemplateResponse(request, "book/edit/edit_book.html", data)
with transaction.atomic():
book = form.save()
parent_work = get_object_or_404(models.Work, id=parent_work_id)
book.parent_work = parent_work
if authors:
book.authors.add(*authors)
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(f"/book/{book.id}")
def copy_form(data):
"""helper to re-create the date fields in the form"""
formcopy = data["form"].data.copy()
try:
formcopy["first_published_date"] = dateparse(formcopy["first_published_date"])
except (MultiValueDictKeyError, ValueError):
pass
try:
formcopy["published_date"] = dateparse(formcopy["published_date"])
except (MultiValueDictKeyError, ValueError):
pass
data["form"].data = formcopy
return data
def add_authors(request, data):
"""helper for adding authors"""
add_author = [author for author in request.POST.getlist("add_author") if author] add_author = [author for author in request.POST.getlist("add_author") if author]
if add_author: if not add_author:
return data
data["add_author"] = add_author data["add_author"] = add_author
data["author_matches"] = [] data["author_matches"] = []
data["isni_matches"] = [] data["isni_matches"] = []
# 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")
data["cover_url"] = request.POST.get("cover-url")
for author in add_author: for author in add_author:
# filter out empty author fields
if not author: if not author:
continue continue
# check for existing authors # check for existing authors
vector = SearchVector("name", weight="A") + SearchVector( vector = SearchVector("name", weight="A") + SearchVector("aliases", weight="B")
"aliases", weight="B"
)
author_matches = ( author_matches = (
models.Author.objects.annotate(search=vector) models.Author.objects.annotate(search=vector)
@ -97,53 +208,23 @@ class EditBook(View):
"existing_isnis": exists, "existing_isnis": exists,
} }
) )
return data
# 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"] = book_search.search(
f'{form.cleaned_data.get("title")} {author_text}',
min_confidence=0.5,
)[:5]
# either of the above cases requires additional confirmation @require_POST
if add_author or not book: @permission_required("bookwyrm.edit_book", raise_exception=True)
# creting a book or adding an author to a book needs another step def create_book_from_data(request):
data["confirm_mode"] = True """create a book with starter data"""
# this isn't preserved because it isn't part of the form obj author_ids = findall(r"\d+", request.POST.get("authors"))
data["remove_authors"] = request.POST.getlist("remove_authors") book = {
data["cover_url"] = request.POST.get("cover-url") "parent_work": {"id": request.POST.get("parent_work")},
"authors": models.Author.objects.filter(id__in=author_ids).all(),
"subjects": request.POST.getlist("subjects"),
}
# make sure the dates are passed in as datetime, they're currently a string data = {"book": book, "form": forms.EditionForm(request.POST)}
# QueryDicts are immutable, we need to copy
formcopy = data["form"].data.copy()
try:
formcopy["first_published_date"] = dateparse(
formcopy["first_published_date"]
)
except (MultiValueDictKeyError, ValueError):
pass
try:
formcopy["published_date"] = dateparse(formcopy["published_date"])
except (MultiValueDictKeyError, ValueError):
pass
data["form"].data = formcopy
return TemplateResponse(request, "book/edit/edit_book.html", data) return TemplateResponse(request, "book/edit/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(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(f"/book/{book.id}")
@method_decorator(login_required, name="dispatch") @method_decorator(login_required, name="dispatch")
@method_decorator( @method_decorator(
@ -168,6 +249,13 @@ class ConfirmEditBook(View):
# save book # save book
book = form.save() book = form.save()
# add known authors
authors = None
if request.POST.get("authors"):
author_ids = findall(r"\d+", request.POST["authors"])
authors = models.Author.objects.filter(id__in=author_ids)
book.authors.add(*authors)
# get or create author as needed # get or create author as needed
for i in range(int(request.POST.get("author-match-count", 0))): for i in range(int(request.POST.get("author-match-count", 0))):
match = request.POST.get(f"author_match-{i}") match = request.POST.get(f"author_match-{i}")
@ -201,7 +289,7 @@ class ConfirmEditBook(View):
book.authors.add(author) book.authors.add(author)
# create work, if needed # create work, if needed
if not book_id: if not book.parent_work:
work_match = request.POST.get("parent_work") work_match = request.POST.get("parent_work")
if work_match and work_match != "0": if work_match and work_match != "0":
work = get_object_or_404(models.Work, id=work_match) work = get_object_or_404(models.Work, id=work_match)

View file

@ -11,7 +11,7 @@ from django.template.response import TemplateResponse
from django.views import View from django.views import View
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from bookwyrm import models from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request from bookwyrm.views.helpers import is_api_request
@ -65,6 +65,7 @@ class Editions(View):
page.number, on_each_side=2, on_ends=1 page.number, on_each_side=2, on_ends=1
), ),
"work": work, "work": work,
"work_form": forms.EditionFromWorkForm(instance=work),
"languages": languages, "languages": languages,
"formats": set( "formats": set(
e.physical_format.lower() for e in editions if e.physical_format e.physical_format.lower() for e in editions if e.physical_format