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"}),
}
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 %}
{% if book.parent_work.editions.count > 1 %}
<p>{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}<a href="{{ path }}/editions">{{ count }} editions</a>{% endblocktrans %}</p>
{% endif %}
{% with work=book.parent_work %}
<p>
<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>
{# user's relationship to the book #}

View file

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

View file

@ -10,6 +10,8 @@
{% csrf_token %}
<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="column is-half">
<section class="block">
@ -175,6 +177,8 @@
</h2>
<div class="box">
{% 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>
{% for author in book.authors.all %}
<div class="is-flex is-justify-content-space-between">

View file

@ -46,7 +46,36 @@
{% endfor %}
</div>
<div>
<div class="block">
{% include 'snippets/pagination.html' with page=editions path=request.path %}
</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 %}

View file

@ -60,7 +60,7 @@ class EditBookViews(TestCase):
def test_edit_book_create_page(self):
"""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.user = self.local_user
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}/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(rf"{BOOK_PATH}/editions(.json)?/?$", views.Editions.as_view()),
re_path(

View file

@ -39,7 +39,12 @@ from .books.books import (
resolve_book,
)
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.links import BookFileLinks, AddFileLink, delete_link

View file

@ -1,5 +1,5 @@
""" the good stuff! the books! """
from re import sub
from re import sub, findall
from dateutil.parser import parse as dateparse
from django.contrib.auth.decorators import login_required, permission_required
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.utils.datastructures import MultiValueDictKeyError
from django.utils.decorators import method_decorator
from django.views.decorators.http import require_POST
from django.views import View
from bookwyrm import book_search, forms, models
@ -30,105 +31,28 @@ from .books import set_cover_from_url
class EditBook(View):
"""edit a book"""
def get(self, request, book_id=None):
def get(self, request, book_id):
"""info about a book"""
book = None
if book_id:
book = get_edition(book_id)
if not book.description:
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)}
return TemplateResponse(request, "book/edit/edit_book.html", data)
# pylint: disable=too-many-locals
def post(self, request, book_id=None):
def post(self, request, book_id):
"""edit a book cool"""
# returns None if no match is found
book = models.Edition.objects.filter(id=book_id).first()
book = get_object_or_404(models.Edition, id=book_id)
form = forms.EditionForm(request.POST, request.FILES, instance=book)
data = {"book": book, "form": form}
if not form.is_valid():
return TemplateResponse(request, "book/edit/edit_book.html", data)
# filter out empty author fields
add_author = [author for author in request.POST.getlist("add_author") if author]
if add_author:
data["add_author"] = add_author
data["author_matches"] = []
data["isni_matches"] = []
for author in add_author:
if not author:
continue
# check for existing authors
vector = SearchVector("name", weight="A") + SearchVector(
"aliases", weight="B"
)
author_matches = (
models.Author.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, author))
.filter(rank__gt=0.4)
.order_by("-rank")[:5]
)
isni_authors = find_authors_by_name(
author, description=True
) # find matches from ISNI API
# dedupe isni authors we already have in the DB
exists = [
i
for i in isni_authors
for a in author_matches
if sub(r"\D", "", str(i.isni)) == sub(r"\D", "", str(a.isni))
]
# pylint: disable=cell-var-from-loop
matches = list(filter(lambda x: x not in exists, isni_authors))
# combine existing and isni authors
matches.extend(author_matches)
data["author_matches"].append(
{
"name": author.strip(),
"matches": matches,
"existing_isnis": exists,
}
)
# 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]
data = add_authors(request, data)
# 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")
data["cover_url"] = request.POST.get("cover-url")
# make sure the dates are passed in as datetime, they're currently a string
# 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
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")
@ -136,15 +60,172 @@ class EditBook(View):
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]
if not add_author:
return data
data["add_author"] = add_author
data["author_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:
# filter out empty author fields
if not author:
continue
# check for existing authors
vector = SearchVector("name", weight="A") + SearchVector("aliases", weight="B")
author_matches = (
models.Author.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, author))
.filter(rank__gt=0.4)
.order_by("-rank")[:5]
)
isni_authors = find_authors_by_name(
author, description=True
) # find matches from ISNI API
# dedupe isni authors we already have in the DB
exists = [
i
for i in isni_authors
for a in author_matches
if sub(r"\D", "", str(i.isni)) == sub(r"\D", "", str(a.isni))
]
# pylint: disable=cell-var-from-loop
matches = list(filter(lambda x: x not in exists, isni_authors))
# combine existing and isni authors
matches.extend(author_matches)
data["author_matches"].append(
{
"name": author.strip(),
"matches": matches,
"existing_isnis": exists,
}
)
return data
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)
def create_book_from_data(request):
"""create a book with starter data"""
author_ids = findall(r"\d+", request.POST.get("authors"))
book = {
"parent_work": {"id": request.POST.get("parent_work")},
"authors": models.Author.objects.filter(id__in=author_ids).all(),
"subjects": request.POST.getlist("subjects"),
}
data = {"book": book, "form": forms.EditionForm(request.POST)}
return TemplateResponse(request, "book/edit/edit_book.html", data)
@method_decorator(login_required, name="dispatch")
@method_decorator(
permission_required("bookwyrm.edit_book", raise_exception=True), name="dispatch"
@ -168,6 +249,13 @@ class ConfirmEditBook(View):
# save book
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
for i in range(int(request.POST.get("author-match-count", 0))):
match = request.POST.get(f"author_match-{i}")
@ -201,7 +289,7 @@ class ConfirmEditBook(View):
book.authors.add(author)
# create work, if needed
if not book_id:
if not book.parent_work:
work_match = request.POST.get("parent_work")
if work_match and work_match != "0":
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.decorators.http import require_POST
from bookwyrm import models
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.views.helpers import is_api_request
@ -65,6 +65,7 @@ class Editions(View):
page.number, on_each_side=2, on_ends=1
),
"work": work,
"work_form": forms.EditionFromWorkForm(instance=work),
"languages": languages,
"formats": set(
e.physical_format.lower() for e in editions if e.physical_format