Add editions view

This commit is contained in:
Mouse Reeve 2022-02-25 16:40:34 -08:00
parent 1d99e455e8
commit c67f92af46
7 changed files with 196 additions and 77 deletions

View file

@ -264,17 +264,40 @@ class FileLinkForm(CustomForm):
) )
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",
]
class EditionForm(CustomForm): class EditionForm(CustomForm):
class Meta: class Meta:
model = models.Edition model = models.Edition
exclude = [ exclude = [
"authors",
"parent_work",
"remote_id", "remote_id",
"origin_id", "origin_id",
"created_date", "created_date",
"updated_date", "updated_date",
"edition_rank", "edition_rank",
"authors",
"parent_work",
"shelves", "shelves",
"connector", "connector",
"search_vector", "search_vector",

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 %}
@ -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

@ -9,6 +9,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">
@ -123,8 +125,11 @@
</h2> </h2>
<div class="box"> <div class="box">
{% if book.authors.exists %} {% if book.authors.exists %}
{# preserve authors if the book is unsaved #}
{{ form.authors.as_hidden }}
<fieldset> <fieldset>
{% for author in book.authors.all %} {% for author in book.authors.all %}
{{ form.authors.as_hidden }}
<div class="is-flex is-justify-content-space-between"> <div class="is-flex is-justify-content-space-between">
<label class="label mb-2"> <label class="label mb-2">
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %} aria-describedby="desc_remove_author_{{author.id}}"> <input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %} aria-describedby="desc_remove_author_{{author.id}}">

View file

@ -496,7 +496,8 @@ 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

@ -37,7 +37,7 @@ 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
@ -31,18 +31,16 @@ 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 book = get_edition(book_id)
if book_id: if not book.description:
book = get_edition(book_id) book.description = book.parent_work.description
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, "book/edit/edit_book.html", data) return TemplateResponse(request, "book/edit/edit_book.html", data)
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
def post(self, request, book_id=None): def post(self, request, book_id):
"""edit a book cool""" """edit a book cool"""
# returns None if no match is found # returns None if no match is found
book = models.Edition.objects.filter(id=book_id).first() book = models.Edition.objects.filter(id=book_id).first()
@ -50,66 +48,13 @@ class EditBook(View):
data = {"book": book, "form": form} data = {"book": book, "form": form}
if not form.is_valid(): if not form.is_valid():
print("FORM NOT VALID", form.errors)
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)
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]
# either of the above cases requires additional confirmation # either of the above cases requires additional confirmation
if add_author or not book: if data.get("add_author"):
# creting a book or adding an author to a book needs another step # creting a book or adding an author to a book needs another step
data["confirm_mode"] = True data["confirm_mode"] = True
# this isn't preserved because it isn't part of the form obj # this isn't preserved because it isn't part of the form obj
@ -137,6 +82,85 @@ class EditBook(View):
book.authors.remove(author_id) book.authors.remove(author_id)
book = form.save(commit=False) 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}
if not form.is_valid():
print(form.errors)
return TemplateResponse(request, "book/edit/edit_book.html", data)
data = add_authors(request, data)
author_text = ", ".join(data.get("add_author", []))
# check if this is an edition of an existing work
data["book_matches"] = book_search.search(
f'{form.cleaned_data.get("title")} {author_text}',
min_confidence=0.1,
)[:5]
parent_work_id = request.POST.get("parent_work")
if not parent_work_id or data.get("add_authors"):
# 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()
formcopy["parent_work"] = parent_work_id
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)
book = form.save(commit=False)
parent_work = get_object_or_404(models.Work, id=parent_work_id)
book.parent_work = parent_work
if request.POST.get("authors"):
author_ids = findall(r"\d+", request.POST["authors"])
# django can't parse the authors form element
book.authors.add(
*models.Author.objects.filter(id__in=author_ids)
)
url = request.POST.get("cover-url") url = request.POST.get("cover-url")
if url: if url:
image = set_cover_from_url(url) image = set_cover_from_url(url)
@ -145,14 +169,73 @@ class EditBook(View):
book.save() book.save()
return redirect(f"/book/{book.id}") return redirect(f"/book/{book.id}")
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"] = []
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 @require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True) @permission_required("bookwyrm.edit_book", raise_exception=True)
def create_book_from_data(request): def create_book_from_data(request):
"""create a book with starter data""" """create a book with starter data"""
data = {"form": forms.EditionForm(request.POST)} author_ids = findall(r"\d+", request.POST.get("authors"))
return TemplateResponse(request, "book/edit/edit_book.html", data) book = {
"parent_work": {"id": request.POST.get("parent_work")},
"authors": models.Author.objects.filter(
id__in=author_ids
).all(),
}
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(login_required, name="dispatch")
@ -211,7 +294,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
@ -61,6 +61,7 @@ class Editions(View):
data = { data = {
"editions": paginated.get_page(request.GET.get("page")), "editions": paginated.get_page(request.GET.get("page")),
"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