Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-03-19 11:01:32 -07:00
commit 8586f0fe75
15 changed files with 477 additions and 328 deletions

View file

@ -0,0 +1,24 @@
// Toggle all checkboxes.
window.onload = function() {
document
.querySelectorAll('[data-action="toggle-all"]')
.forEach(input => {
input.addEventListener('change', toggleAllCheckboxes);
});
};
/**
* Toggle all descendant checkboxes of a target.
*
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
*
* @param {Event} event - change Event
* @return {undefined}
*/
function toggleAllCheckboxes(event) {
const mainCheckbox = event.target;
document
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
}

View file

@ -8,17 +8,6 @@ window.onload = function() {
Array.from(document.getElementsByClassName('interaction'))
.forEach(t => t.onsubmit = interact);
// Toggle all checkboxes.
document
.querySelectorAll('[data-action="toggle-all"]')
.forEach(input => {
input.addEventListener('change', toggleAllCheckboxes);
});
// tab groups
Array.from(document.getElementsByClassName('tab-group'))
.forEach(t => new TabGroup(t));
// handle aria settings on menus
Array.from(document.getElementsByClassName('pulldown-menu'))
.forEach(t => t.onclick = toggleMenu);
@ -113,22 +102,6 @@ function interact(e) {
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
}
/**
* Toggle all descendant checkboxes of a target.
*
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
*
* @param {Event} event - change Event
* @return {undefined}
*/
function toggleAllCheckboxes(event) {
const mainCheckbox = event.target;
document
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
}
function toggleMenu(e) {
var el = e.currentTarget;
var expanded = el.getAttribute('aria-expanded') == 'false';
@ -174,258 +147,3 @@ function removeClass(el, className) {
}
el.className = classes.join(' ');
}
/*
* The content below is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
* Heavily modified to web component by Zach Leatherman
* Modified back to vanilla JavaScript with support for Bulma markup and nested tabs by Ned Zimmerman
*/
class TabGroup {
constructor(container) {
this.container = container;
this.tablist = this.container.querySelector('[role="tablist"]');
this.buttons = this.tablist.querySelectorAll('[role="tab"]');
this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]');
this.delay = this.determineDelay();
if(!this.tablist || !this.buttons.length || !this.panels.length) {
return;
}
this.keys = this.keys();
this.direction = this.direction();
this.initButtons();
this.initPanels();
}
keys() {
return {
end: 35,
home: 36,
left: 37,
up: 38,
right: 39,
down: 40
};
}
// Add or substract depending on key pressed
direction() {
return {
37: -1,
38: -1,
39: 1,
40: 1
};
}
initButtons() {
let count = 0;
for(let button of this.buttons) {
let isSelected = button.getAttribute("aria-selected") === "true";
button.setAttribute("tabindex", isSelected ? "0" : "-1");
button.addEventListener('click', this.clickEventListener.bind(this));
button.addEventListener('keydown', this.keydownEventListener.bind(this));
button.addEventListener('keyup', this.keyupEventListener.bind(this));
button.index = count++;
}
}
initPanels() {
let selectedPanelId = this.tablist.querySelector('[role="tab"][aria-selected="true"]').getAttribute("aria-controls");
for(let panel of this.panels) {
if(panel.getAttribute("id") !== selectedPanelId) {
panel.setAttribute("hidden", "");
}
panel.setAttribute("tabindex", "0");
}
}
clickEventListener(event) {
let button = event.target.closest('a');
event.preventDefault();
this.activateTab(button, false);
}
// Handle keydown on tabs
keydownEventListener(event) {
var key = event.keyCode;
switch (key) {
case this.keys.end:
event.preventDefault();
// Activate last tab
this.activateTab(this.buttons[this.buttons.length - 1]);
break;
case this.keys.home:
event.preventDefault();
// Activate first tab
this.activateTab(this.buttons[0]);
break;
// Up and down are in keydown
// because we need to prevent page scroll >:)
case this.keys.up:
case this.keys.down:
this.determineOrientation(event);
break;
}
}
// Handle keyup on tabs
keyupEventListener(event) {
var key = event.keyCode;
switch (key) {
case this.keys.left:
case this.keys.right:
this.determineOrientation(event);
break;
}
}
// When a tablists aria-orientation is set to vertical,
// only up and down arrow should function.
// In all other cases only left and right arrow function.
determineOrientation(event) {
var key = event.keyCode;
var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical';
var proceed = false;
if (vertical) {
if (key === this.keys.up || key === this.keys.down) {
event.preventDefault();
proceed = true;
}
}
else {
if (key === this.keys.left || key === this.keys.right) {
proceed = true;
}
}
if (proceed) {
this.switchTabOnArrowPress(event);
}
}
// Either focus the next, previous, first, or last tab
// depending on key pressed
switchTabOnArrowPress(event) {
var pressed = event.keyCode;
for (let button of this.buttons) {
button.addEventListener('focus', this.focusEventHandler.bind(this));
}
if (this.direction[pressed]) {
var target = event.target;
if (target.index !== undefined) {
if (this.buttons[target.index + this.direction[pressed]]) {
this.buttons[target.index + this.direction[pressed]].focus();
}
else if (pressed === this.keys.left || pressed === this.keys.up) {
this.focusLastTab();
}
else if (pressed === this.keys.right || pressed == this.keys.down) {
this.focusFirstTab();
}
}
}
}
// Activates any given tab panel
activateTab (tab, setFocus) {
if(tab.getAttribute("role") !== "tab") {
tab = tab.closest('[role="tab"]');
}
setFocus = setFocus || true;
// Deactivate all other tabs
this.deactivateTabs();
// Remove tabindex attribute
tab.removeAttribute('tabindex');
// Set the tab as selected
tab.setAttribute('aria-selected', 'true');
// Give the tab parent an is-active class
tab.parentNode.classList.add('is-active');
// Get the value of aria-controls (which is an ID)
var controls = tab.getAttribute('aria-controls');
// Remove hidden attribute from tab panel to make it visible
document.getElementById(controls).removeAttribute('hidden');
// Set focus when required
if (setFocus) {
tab.focus();
}
}
// Deactivate all tabs and tab panels
deactivateTabs() {
for (let button of this.buttons) {
button.parentNode.classList.remove('is-active');
button.setAttribute('tabindex', '-1');
button.setAttribute('aria-selected', 'false');
button.removeEventListener('focus', this.focusEventHandler.bind(this));
}
for (let panel of this.panels) {
panel.setAttribute('hidden', 'hidden');
}
}
focusFirstTab() {
this.buttons[0].focus();
}
focusLastTab() {
this.buttons[this.buttons.length - 1].focus();
}
// Determine whether there should be a delay
// when user navigates with the arrow keys
determineDelay() {
var hasDelay = this.tablist.hasAttribute('data-delay');
var delay = 0;
if (hasDelay) {
var delayValue = this.tablist.getAttribute('data-delay');
if (delayValue) {
delay = delayValue;
}
else {
// If no value is specified, default to 300ms
delay = 300;
}
}
return delay;
}
focusEventHandler(event) {
var target = event.target;
setTimeout(this.checkTabFocus.bind(this), this.delay, target);
}
// Only activate tab on focus if it still has focus after the delay
checkTabFocus(target) {
let focused = document.activeElement;
if (target === focused) {
this.activateTab(target, false);
}
}
}

261
bookwyrm/static/js/tabs.js Normal file
View file

@ -0,0 +1,261 @@
// tab groups
window.onload = function() {
Array.from(document.getElementsByClassName('tab-group'))
.forEach(t => new TabGroup(t));
};
/*
* The content below is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
* Heavily modified to web component by Zach Leatherman
* Modified back to vanilla JavaScript with support for Bulma markup and nested tabs by Ned Zimmerman
*/
class TabGroup {
constructor(container) {
this.container = container;
this.tablist = this.container.querySelector('[role="tablist"]');
this.buttons = this.tablist.querySelectorAll('[role="tab"]');
this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]');
this.delay = this.determineDelay();
if(!this.tablist || !this.buttons.length || !this.panels.length) {
return;
}
this.keys = this.keys();
this.direction = this.direction();
this.initButtons();
this.initPanels();
}
keys() {
return {
end: 35,
home: 36,
left: 37,
up: 38,
right: 39,
down: 40
};
}
// Add or substract depending on key pressed
direction() {
return {
37: -1,
38: -1,
39: 1,
40: 1
};
}
initButtons() {
let count = 0;
for(let button of this.buttons) {
let isSelected = button.getAttribute("aria-selected") === "true";
button.setAttribute("tabindex", isSelected ? "0" : "-1");
button.addEventListener('click', this.clickEventListener.bind(this));
button.addEventListener('keydown', this.keydownEventListener.bind(this));
button.addEventListener('keyup', this.keyupEventListener.bind(this));
button.index = count++;
}
}
initPanels() {
let selectedPanelId = this.tablist.querySelector('[role="tab"][aria-selected="true"]').getAttribute("aria-controls");
for(let panel of this.panels) {
if(panel.getAttribute("id") !== selectedPanelId) {
panel.setAttribute("hidden", "");
}
panel.setAttribute("tabindex", "0");
}
}
clickEventListener(event) {
let button = event.target.closest('a');
event.preventDefault();
this.activateTab(button, false);
}
// Handle keydown on tabs
keydownEventListener(event) {
var key = event.keyCode;
switch (key) {
case this.keys.end:
event.preventDefault();
// Activate last tab
this.activateTab(this.buttons[this.buttons.length - 1]);
break;
case this.keys.home:
event.preventDefault();
// Activate first tab
this.activateTab(this.buttons[0]);
break;
// Up and down are in keydown
// because we need to prevent page scroll >:)
case this.keys.up:
case this.keys.down:
this.determineOrientation(event);
break;
}
}
// Handle keyup on tabs
keyupEventListener(event) {
var key = event.keyCode;
switch (key) {
case this.keys.left:
case this.keys.right:
this.determineOrientation(event);
break;
}
}
// When a tablists aria-orientation is set to vertical,
// only up and down arrow should function.
// In all other cases only left and right arrow function.
determineOrientation(event) {
var key = event.keyCode;
var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical';
var proceed = false;
if (vertical) {
if (key === this.keys.up || key === this.keys.down) {
event.preventDefault();
proceed = true;
}
}
else {
if (key === this.keys.left || key === this.keys.right) {
proceed = true;
}
}
if (proceed) {
this.switchTabOnArrowPress(event);
}
}
// Either focus the next, previous, first, or last tab
// depending on key pressed
switchTabOnArrowPress(event) {
var pressed = event.keyCode;
for (let button of this.buttons) {
button.addEventListener('focus', this.focusEventHandler.bind(this));
}
if (this.direction[pressed]) {
var target = event.target;
if (target.index !== undefined) {
if (this.buttons[target.index + this.direction[pressed]]) {
this.buttons[target.index + this.direction[pressed]].focus();
}
else if (pressed === this.keys.left || pressed === this.keys.up) {
this.focusLastTab();
}
else if (pressed === this.keys.right || pressed == this.keys.down) {
this.focusFirstTab();
}
}
}
}
// Activates any given tab panel
activateTab (tab, setFocus) {
if(tab.getAttribute("role") !== "tab") {
tab = tab.closest('[role="tab"]');
}
setFocus = setFocus || true;
// Deactivate all other tabs
this.deactivateTabs();
// Remove tabindex attribute
tab.removeAttribute('tabindex');
// Set the tab as selected
tab.setAttribute('aria-selected', 'true');
// Give the tab parent an is-active class
tab.parentNode.classList.add('is-active');
// Get the value of aria-controls (which is an ID)
var controls = tab.getAttribute('aria-controls');
// Remove hidden attribute from tab panel to make it visible
document.getElementById(controls).removeAttribute('hidden');
// Set focus when required
if (setFocus) {
tab.focus();
}
}
// Deactivate all tabs and tab panels
deactivateTabs() {
for (let button of this.buttons) {
button.parentNode.classList.remove('is-active');
button.setAttribute('tabindex', '-1');
button.setAttribute('aria-selected', 'false');
button.removeEventListener('focus', this.focusEventHandler.bind(this));
}
for (let panel of this.panels) {
panel.setAttribute('hidden', 'hidden');
}
}
focusFirstTab() {
this.buttons[0].focus();
}
focusLastTab() {
this.buttons[this.buttons.length - 1].focus();
}
// Determine whether there should be a delay
// when user navigates with the arrow keys
determineDelay() {
var hasDelay = this.tablist.hasAttribute('data-delay');
var delay = 0;
if (hasDelay) {
var delayValue = this.tablist.getAttribute('data-delay');
if (delayValue) {
delay = delayValue;
}
else {
// If no value is specified, default to 300ms
delay = 300;
}
}
return delay;
}
focusEventHandler(event) {
var target = event.target;
setTimeout(this.checkTabFocus.bind(this), this.delay, target);
}
// Only activate tab on focus if it still has focus after the delay
checkTabFocus(target) {
let focused = document.activeElement;
if (target === focused) {
this.activateTab(target, false);
}
}
}

View file

@ -41,15 +41,10 @@
{% include 'snippets/shelve_button/shelve_button.html' %}
{% if request.user.is_authenticated and not book.cover %}
<div class="box p-2">
<h3 class="title is-6 mb-1">{% trans "Add cover" %}</h3>
<form name="add-cover" method="POST" action="/upload-cover/{{ book.id }}" enctype="multipart/form-data">
{% csrf_token %}
<label class="label">
<input type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover" required>
</label>
<button class="button is-small is-primary" type="submit">{% trans "Add" %}</button>
</form>
<div class="block">
{% trans "Add cover" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %}
{% include 'book/cover_modal.html' with book=book controls_text="add-cover" controls_uid=book.id %}
</div>
{% endif %}

View file

@ -0,0 +1,36 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% trans "Add cover" %}
{% endblock %}
{% block modal-form-open %}
<form name="add-cover" method="POST" action="{% url 'upload-cover' book.id %}" enctype="multipart/form-data">
{% endblock %}
{% block modal-body %}
<section class="modal-card-body columns">
{% csrf_token %}
<div class="column">
<label class="label" for="id_cover">
{% trans "Upload cover:" %}
</label>
<input type="file" name="cover" accept="image/*" enctype="multipart/form-data" id="id_cover">
</div>
<div class="column">
<label class="label" for="id_cover_url">
{% trans "Load cover from url:" %}
</label>
<input class="input" name="cover-url" id="id_cover_url">
</div>
</section>
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">{% trans "Add" %}</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -85,7 +85,7 @@
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<div class="columns">
<div class="column">
<div class="column is-half">
<section class="block">
<h2 class="title is-4">{% trans "Metadata" %}</h2>
<p class="mb-2"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
@ -151,15 +151,26 @@
</section>
</div>
<div class="column">
<div class="column is-half">
<h2 class="title is-4">{% trans "Cover" %}</h2>
<div class="columns">
<div class="column is-narrow">
{% include 'snippets/book_cover.html' with book=book size="small" %}
</div>
<div class="column is-narrow">
<div class="block">
<h2 class="title is-4">{% trans "Cover" %}</h2>
<p>{{ form.cover }}</p>
<p>
<label class="label" for="id_cover">{% trans "Upload cover:" %}</label>
{{ form.cover }}
</p>
{% if book %}
<p>
<label class="label" for="id_cover_url">
{% trans "Load cover from url:" %}
</label>
<input class="input" name="cover-url" id="id_cover_url">
</p>
{% endif %}
{% for error in form.cover.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}

View file

@ -91,3 +91,6 @@
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/tabs.js"></script>
{% endblock %}

View file

@ -148,3 +148,7 @@
</div>
</div>
{% endspaceless %}{% endblock %}
{% block scripts %}
<script src="/static/js/check_all.js"></script>
{% endblock %}

View file

@ -213,5 +213,6 @@
var csrf_token = '{{ csrf_token }}';
</script>
<script src="/static/js/shared.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -1,7 +1,14 @@
""" test for app action functionality """
from io import BytesIO
import pathlib
from unittest.mock import patch
from PIL import Image
import responses
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
@ -229,3 +236,59 @@ class BookViews(TestCase):
result = view(request, self.work.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_upload_cover_file(self):
""" add a cover via file upload """
self.assertFalse(self.book.cover)
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/default_avi.jpg"
)
form = forms.CoverForm(instance=self.book)
form.data["cover"] = SimpleUploadedFile(
image_file, open(image_file, "rb").read(), content_type="image/jpeg"
)
request = self.factory.post("", form.data)
request.user = self.local_user
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
views.upload_cover(request, self.book.id)
self.assertEqual(delay_mock.call_count, 1)
self.book.refresh_from_db()
self.assertTrue(self.book.cover)
@responses.activate
def test_upload_cover_url(self):
""" add a cover via url """
self.assertFalse(self.book.cover)
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/default_avi.jpg"
)
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
responses.add(
responses.GET,
"http://example.com",
body=output.getvalue(),
status=200,
)
form = forms.CoverForm(instance=self.book)
form.data["cover-url"] = "http://example.com"
request = self.factory.post("", form.data)
request.user = self.local_user
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
views.upload_cover(request, self.book.id)
self.assertEqual(delay_mock.call_count, 1)
self.book.refresh_from_db()
self.assertTrue(self.book.cover)

View file

@ -5,6 +5,7 @@ from PIL import Image
from django.contrib.auth.models import AnonymousUser
from django.core.files.base import ContentFile
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
@ -157,28 +158,31 @@ class UserViews(TestCase):
self.assertEqual(self.local_user.email, "wow@email.com")
# idk how to mock the upload form, got tired of triyng to make it work
# def test_edit_user_avatar(self):
# ''' use a form to update a user '''
# view = views.EditUser.as_view()
# form = forms.EditUserForm(instance=self.local_user)
# form.data['name'] = 'New Name'
# form.data['email'] = 'wow@email.com'
# image_file = pathlib.Path(__file__).parent.joinpath(
# '../../static/images/no_cover.jpg')
# image = Image.open(image_file)
# form.files['avatar'] = SimpleUploadedFile(
# image_file, open(image_file), content_type='image/jpeg')
# request = self.factory.post('', form.data, form.files)
# request.user = self.local_user
def test_edit_user_avatar(self):
""" use a form to update a user """
view = views.EditUser.as_view()
form = forms.EditUserForm(instance=self.local_user)
form.data["name"] = "New Name"
form.data["email"] = "wow@email.com"
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/no_cover.jpg"
)
form.data["avatar"] = SimpleUploadedFile(
image_file, open(image_file, "rb").read(), content_type="image/jpeg"
)
request = self.factory.post("", form.data)
request.user = self.local_user
# with patch('bookwyrm.models.activitypub_mixin.broadcast_task.delay') \
# as delay_mock:
# view(request)
# self.assertEqual(delay_mock.call_count, 1)
# self.assertEqual(self.local_user.name, 'New Name')
# self.assertEqual(self.local_user.email, 'wow@email.com')
# self.assertIsNotNone(self.local_user.avatar)
# self.assertEqual(self.local_user.avatar.size, (120, 120))
with patch(
"bookwyrm.models.activitypub_mixin.broadcast_task.delay"
) as delay_mock:
view(request)
self.assertEqual(delay_mock.call_count, 1)
self.assertEqual(self.local_user.name, "New Name")
self.assertEqual(self.local_user.email, "wow@email.com")
self.assertIsNotNone(self.local_user.avatar)
self.assertEqual(self.local_user.avatar.width, 120)
self.assertEqual(self.local_user.avatar.height, 120)
def test_crop_avatar(self):
""" reduce that image size """

View file

@ -152,7 +152,9 @@ urlpatterns = [
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<book_id>\d+)/?$", views.upload_cover),
re_path(
r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover, name="upload-cover"
),
re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description),
re_path(r"^resolve-book/?$", views.resolve_book),
re_path(r"^switch-edition/?$", views.switch_edition),

View file

@ -1,6 +1,9 @@
""" the good stuff! the books! """
from uuid import uuid4
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import SearchRank, SearchVector
from django.core.files.base import ContentFile
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Avg, Q
@ -14,6 +17,7 @@ from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.connectors.abstract_connector import get_image
from bookwyrm.settings import PAGE_LENGTH
from .helpers import is_api_request, get_activity_feed, get_edition
from .helpers import privacy_filter
@ -97,7 +101,7 @@ class Book(View):
"readthroughs": readthroughs,
"path": "/book/%s" % book_id,
}
return TemplateResponse(request, "book.html", data)
return TemplateResponse(request, "book/book.html", data)
@method_decorator(login_required, name="dispatch")
@ -115,7 +119,7 @@ class EditBook(View):
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)
return TemplateResponse(request, "book/edit_book.html", data)
def post(self, request, book_id=None):
""" edit a book cool """
@ -125,7 +129,7 @@ class EditBook(View):
data = {"book": book, "form": form}
if not form.is_valid():
return TemplateResponse(request, "edit_book.html", data)
return TemplateResponse(request, "book/edit_book.html", data)
add_author = request.POST.get("add_author")
# we're adding an author through a free text field
@ -169,13 +173,19 @@ class EditBook(View):
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)
return TemplateResponse(request, "book/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(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("/book/%s" % book.id)
@ -194,7 +204,7 @@ class ConfirmEditBook(View):
data = {"book": book, "form": form}
if not form.is_valid():
return TemplateResponse(request, "edit_book.html", data)
return TemplateResponse(request, "book/edit_book.html", data)
with transaction.atomic():
# save book
@ -256,18 +266,35 @@ class Editions(View):
def upload_cover(request, book_id):
""" upload a new cover """
book = get_object_or_404(models.Edition, id=book_id)
book.last_edited_by = request.user
url = request.POST.get("cover-url")
if url:
image = set_cover_from_url(url)
book.cover.save(*image)
form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid():
return redirect("/book/%d" % book.id)
book.last_edited_by = request.user
form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid() or not form.files.get("cover"):
return redirect("/book/%d" % book.id)
book.cover = form.files["cover"]
book.save()
return redirect("/book/%s" % book.id)
def set_cover_from_url(url):
""" load it from a url """
image_file = get_image(url)
if not image_file:
return None
image_name = str(uuid4()) + "." + url.split(".")[-1]
image_content = ContentFile(image_file.content)
return [image_name, image_content]
@login_required
@require_POST
@permission_required("bookwyrm.edit_book", raise_exception=True)

View file

@ -173,7 +173,7 @@ class EditUser(View):
# set the name to a hash
extension = form.files["avatar"].name.split(".")[-1]
filename = "%s.%s" % (uuid4(), extension)
user.avatar.save(filename, image)
user.avatar.save(filename, image, save=False)
user.save()
return redirect(user.local_path)

View file

@ -1,5 +1,5 @@
celery==4.4.2
Django==3.0.7
Django==3.1.6
django-model-utils==4.0.0
environs==7.2.0
flower==0.9.4