Merge pull request #1647 from joachimesque/list-embed

List embed
This commit is contained in:
Mouse Reeve 2021-12-09 10:53:16 -08:00 committed by GitHub
commit 2ffddeaa1f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 384 additions and 17 deletions

View file

@ -0,0 +1,29 @@
# Generated by Django 3.2.5 on 2021-12-04 10:55
from django.db import migrations, models
import uuid
def gen_uuid(apps, schema_editor):
"""sets an unique UUID for embed_key"""
book_lists = apps.get_model("bookwyrm", "List")
db_alias = schema_editor.connection.alias
for book_list in book_lists.objects.using(db_alias).all():
book_list.embed_key = uuid.uuid4()
book_list.save(broadcast=False)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0119_user_feed_status_types"),
]
operations = [
migrations.AddField(
model_name="list",
name="embed_key",
field=models.UUIDField(editable=False, null=True, unique=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
]

View file

@ -1,4 +1,6 @@
""" make a list of books!! """ """ make a list of books!! """
import uuid
from django.apps import apps from django.apps import apps
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
@ -43,6 +45,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
through="ListItem", through="ListItem",
through_fields=("book_list", "book"), through_fields=("book_list", "book"),
) )
embed_key = models.UUIDField(unique=True, null=True, editable=False)
activity_serializer = activitypub.BookList activity_serializer = activitypub.BookList
def get_remote_id(self): def get_remote_id(self):
@ -105,6 +108,12 @@ class List(OrderedCollectionMixin, BookWyrmModel):
group=None, curation="closed" group=None, curation="closed"
) )
def save(self, *args, **kwargs):
"""on save, update embed_key and avoid clash with existing code"""
if not self.embed_key:
self.embed_key = uuid.uuid4()
return super().save(*args, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel): class ListItem(CollectionItemMixin, BookWyrmModel):
"""ok""" """ok"""

View file

@ -20,6 +20,10 @@ body {
overflow: visible; overflow: visible;
} }
.card.has-border {
border: 1px solid #eee;
}
.scroll-x { .scroll-x {
overflow: hidden; overflow: hidden;
overflow-x: auto; overflow-x: auto;

View file

@ -66,6 +66,9 @@ let BookWyrm = new class {
document.querySelectorAll('input[type="file"]').forEach( document.querySelectorAll('input[type="file"]').forEach(
bookwyrm.disableIfTooLarge.bind(bookwyrm) bookwyrm.disableIfTooLarge.bind(bookwyrm)
); );
document.querySelectorAll('[data-copytext]').forEach(
bookwyrm.copyText.bind(bookwyrm)
);
}); });
} }
@ -445,4 +448,38 @@ let BookWyrm = new class {
parent.appendChild(label) parent.appendChild(label)
parent.appendChild(input) parent.appendChild(input)
} }
/**
* Set up a "click-to-copy" component from a textarea element
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
* attributes.
*
* @param {object} node - DOM node of the text container
* @return {undefined}
*/
copyText(textareaEl) {
const text = textareaEl.textContent;
const copyButtonEl = document.createElement('button');
copyButtonEl.textContent = textareaEl.dataset.copytextLabel;
copyButtonEl.classList.add(
"mt-2",
"button",
"is-small",
"is-fullwidth",
"is-primary",
"is-light"
);
copyButtonEl.addEventListener('click', () => {
navigator.clipboard.writeText(text).then(function() {
textareaEl.classList.add('is-success');
copyButtonEl.classList.replace('is-primary', 'is-success');
copyButtonEl.textContent = textareaEl.dataset.copytextSuccess;
});
});
textareaEl.parentNode.appendChild(copyButtonEl)
}
}(); }();

View file

@ -0,0 +1,53 @@
{% load layout %}
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}">
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
<base target="_blank">
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
</head>
<body>
<header class="section py-3">
<a href="/" class="is-flex is-align-items-center">
<img class="image logo is-flex-shrink-0" style="height: 32px" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
<span class="title is-5 ml-2">{{ site.name }}</span>
</a>
</header>
<main class="section py-3">
{% block content %}
{% endblock %}
</main>
<footer class="section py-3">
<p>
<a href="{% url 'about' %}">
{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}
</a>
</p>
{% if site.admin_email %}
<p>
<a href="mailto:{{ site.admin_email }}">
{% trans "Contact site admin" %}
</a>
</p>
{% endif %}
<p>
<a href="https://joinbookwyrm.com/">
{% trans "Join Bookwyrm" %}
</a>
</p>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -34,7 +34,7 @@
<div class="container"> <div class="container">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item" href="/"> <a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="Home page"> <img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
</a> </a>
<form class="navbar-item column" action="{% url 'search' %}"> <form class="navbar-item column" action="{% url 'search' %}">
<div class="field has-addons"> <div class="field has-addons">

View file

@ -0,0 +1,59 @@
{% extends 'embed-layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load bookwyrm_group_tags %}
{% load markdown %}
{% block title %}{% blocktrans with list_name=list.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}}{% endblocktrans %}{% endblock title %}
{% block content %}
<div class="mt-3">
<h1 class="title is-4">
{{ list.name }}
<span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h1>
<p class="subtitle is-size-6">
{% include 'lists/created_text.html' with list=list %}
{% blocktrans with site_name=site.name %}on <a href="/">{{ site_name }}</a>{% endblocktrans %}
</p>
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=list.description %}
</div>
<section>
{% if not items.object_list.exists %}
<p>{% trans "This list is currently empty" %}</p>
{% else %}
<ol start="{{ items.start_index }}" class="ordered-list">
{% for item in items %}
{% with book=item.book %}
<li class="mb-5 card is-shadowless has-border">
<div class="card-content p-0 mb-0 columns is-gapless is-mobile">
<div class="column is-3-mobile is-2-tablet is-cover align to-t">
<a href="{{ item.book.local_path }}" aria-hidden="true">
{% include 'snippets/book_cover.html' with cover_class='is-w-auto is-h-m-tablet is-align-items-flex-start' size='medium' %}
</a>
</div>
<div class="column mx-3 my-2">
<h2 class="title is-6 mb-1">
{% include 'snippets/book_titleby.html' %}
</h2>
<p>
{% include 'snippets/stars.html' with rating=item.book|rating:request.user %}
</p>
<div>
{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}
</div>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
</ol>
{% endif %}
{% include "snippets/pagination.html" with page=items %}
</section>
</div>
{% endblock %}

View file

@ -186,6 +186,13 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% endif %} {% endif %}
<div>
<h2 class="title is-5 mt-6" id="embed-label">
{% trans "Embed this list on a website" %}
</h2>
<textarea readonly class="textarea is-small" aria-labelledby="embed-label" data-copytext data-copytext-label="{% trans 'Copy embed code' %}" data-copytext-success="{% trans 'Copied!' %}"><iframe style="border-width:0;" id="bookwyrm_list_embed" width="400" height="600" title="{% blocktrans with list_name=list.name site_name=site.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}} on {{ site_name }}{% endblocktrans %}" src="{{ embed_url }}"></iframe></textarea>
</div>
</section> </section>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,6 +1,7 @@
""" testing models """ """ testing models """
from unittest.mock import patch from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from uuid import UUID
from bookwyrm import models, settings from bookwyrm import models, settings
@ -80,3 +81,12 @@ class List(TestCase):
self.assertEqual(item.book_list.privacy, "public") self.assertEqual(item.book_list.privacy, "public")
self.assertEqual(item.privacy, "direct") self.assertEqual(item.privacy, "direct")
self.assertEqual(item.recipients, []) self.assertEqual(item.recipients, [])
def test_embed_key(self, _):
"""embed_key should never be empty"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
book_list = models.List.objects.create(
name="Test List", user=self.local_user
)
self.assertIsInstance(book_list.embed_key, UUID)

View file

@ -3,6 +3,7 @@ import json
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.http.response import Http404
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
@ -385,3 +386,46 @@ class ListViews(TestCase):
result = view(request, self.local_user.username) result = view(request, self.local_user.username)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
def test_embed_call_without_key(self):
"""there are so many views, this just makes sure it DOESNT load"""
view = views.unsafe_embed_list
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
with patch("bookwyrm.views.list.is_api_request") as is_api:
is_api.return_value = False
with self.assertRaises(Http404):
result = view(request, self.list.id, "")
def test_embed_call_with_key(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.unsafe_embed_list
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
embed_key = str(self.list.embed_key.hex)
with patch("bookwyrm.views.list.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.list.id, embed_key)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -337,6 +337,11 @@ urlpatterns = [
), ),
re_path(r"^save-list/(?P<list_id>\d+)/?$", views.save_list, name="list-save"), re_path(r"^save-list/(?P<list_id>\d+)/?$", views.save_list, name="list-save"),
re_path(r"^unsave-list/(?P<list_id>\d+)/?$", views.unsave_list, name="list-unsave"), re_path(r"^unsave-list/(?P<list_id>\d+)/?$", views.unsave_list, name="list-unsave"),
re_path(
r"^list/(?P<list_id>\d+)/embed/(?P<list_key>[0-9a-f]+)?$",
views.unsafe_embed_list,
name="embed-list",
),
# User books # User books
re_path(rf"{USER_PATH}/books/?$", views.Shelf.as_view(), name="user-shelves"), re_path(rf"{USER_PATH}/books/?$", views.Shelf.as_view(), name="user-shelves"),
re_path( re_path(

View file

@ -84,7 +84,7 @@ from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost from .interaction import Favorite, Unfavorite, Boost, Unboost
from .isbn import Isbn from .isbn import Isbn
from .list import Lists, SavedLists, List, Curate, UserLists from .list import Lists, SavedLists, List, Curate, UserLists
from .list import save_list, unsave_list, delete_list from .list import save_list, unsave_list, delete_list, unsafe_embed_list
from .notifications import Notifications from .notifications import Notifications
from .outbox import Outbox from .outbox import Outbox
from .reading import create_readthrough, delete_readthrough, delete_progressupdate from .reading import create_readthrough, delete_readthrough, delete_progressupdate

View file

@ -7,13 +7,14 @@ from django.core.paginator import Paginator
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models import Avg, Count, DecimalField, Q, Max from django.db.models import Avg, Count, DecimalField, Q, Max
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import HttpResponseBadRequest, HttpResponse from django.http import HttpResponseBadRequest, HttpResponse, Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.urls import reverse from django.urls import reverse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
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 django.views.decorators.clickjacking import xframe_options_exempt
from bookwyrm import book_search, forms, models from bookwyrm import book_search, forms, models
from bookwyrm.activitypub import ActivitypubResponse from bookwyrm.activitypub import ActivitypubResponse
@ -167,6 +168,14 @@ class List(View):
][: 5 - len(suggestions)] ][: 5 - len(suggestions)]
page = paginated.get_page(request.GET.get("page")) page = paginated.get_page(request.GET.get("page"))
embed_key = str(book_list.embed_key.hex)
embed_url = reverse("embed-list", args=[book_list.id, embed_key])
embed_url = request.build_absolute_uri(embed_url)
if request.GET:
embed_url = f"{embed_url}?{request.GET.urlencode()}"
data = { data = {
"list": book_list, "list": book_list,
"items": page, "items": page,
@ -180,6 +189,7 @@ class List(View):
"sort_form": forms.SortListForm( "sort_form": forms.SortListForm(
{"direction": direction, "sort_by": sort_by} {"direction": direction, "sort_by": sort_by}
), ),
"embed_url": embed_url,
} }
return TemplateResponse(request, "lists/list.html", data) return TemplateResponse(request, "lists/list.html", data)
@ -200,6 +210,60 @@ class List(View):
return redirect(book_list.local_path) return redirect(book_list.local_path)
class EmbedList(View):
"""embeded book list page"""
def get(self, request, list_id, list_key):
"""display a book list"""
book_list = get_object_or_404(models.List, id=list_id)
embed_key = str(book_list.embed_key.hex)
if list_key != embed_key:
raise Http404()
# sort_by shall be "order" unless a valid alternative is given
sort_by = request.GET.get("sort_by", "order")
if sort_by not in ("order", "title", "rating"):
sort_by = "order"
# direction shall be "ascending" unless a valid alternative is given
direction = request.GET.get("direction", "ascending")
if direction not in ("ascending", "descending"):
direction = "ascending"
directional_sort_by = {
"order": "order",
"title": "book__title",
"rating": "average_rating",
}[sort_by]
if direction == "descending":
directional_sort_by = "-" + directional_sort_by
items = book_list.listitem_set.prefetch_related("user", "book", "book__authors")
if sort_by == "rating":
items = items.annotate(
average_rating=Avg(
Coalesce("book__review__rating", 0.0),
output_field=DecimalField(),
)
)
items = items.filter(approved=True).order_by(directional_sort_by)
paginated = Paginator(items, PAGE_LENGTH)
page = paginated.get_page(request.GET.get("page"))
data = {
"list": book_list,
"items": page,
"page_range": paginated.get_elided_page_range(
page.number, on_each_side=2, on_ends=1
),
}
return TemplateResponse(request, "lists/embed-list.html", data)
class Curate(View): class Curate(View):
"""approve or discard list suggestsions""" """approve or discard list suggestsions"""
@ -447,3 +511,11 @@ def normalize_book_list_ordering(book_list_id, start=0, add_offset=0):
if item.order != effective_order: if item.order != effective_order:
item.order = effective_order item.order = effective_order
item.save() item.save()
@xframe_options_exempt
def unsafe_embed_list(request, *args, **kwargs):
"""allows the EmbedList view to be loaded through unsafe iframe origins"""
embed_list_view = EmbedList.as_view()
return embed_list_view(request, *args, **kwargs)

View file

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: 0.0.1\n" "Project-Id-Version: 0.0.1\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-12-07 22:16+0000\n" "POT-Creation-Date: 2021-12-08 15:40+0000\n"
"PO-Revision-Date: 2021-02-28 17:19-0800\n" "PO-Revision-Date: 2021-02-28 17:19-0800\n"
"Last-Translator: Mouse Reeve <mousereeve@riseup.net>\n" "Last-Translator: Mouse Reeve <mousereeve@riseup.net>\n"
"Language-Team: English <LL@li.org>\n" "Language-Team: English <LL@li.org>\n"
@ -1129,6 +1129,25 @@ msgstr ""
msgid "Reset your %(site_name)s password" msgid "Reset your %(site_name)s password"
msgstr "" msgstr ""
#: bookwyrm/templates/embed-layout.html:21 bookwyrm/templates/layout.html:37
#, python-format
msgid "%(site_name)s home page"
msgstr ""
#: bookwyrm/templates/embed-layout.html:34
#: bookwyrm/templates/landing/about.html:7 bookwyrm/templates/layout.html:230
#, python-format
msgid "About %(site_name)s"
msgstr ""
#: bookwyrm/templates/embed-layout.html:40 bookwyrm/templates/layout.html:234
msgid "Contact site admin"
msgstr ""
#: bookwyrm/templates/embed-layout.html:46
msgid "Join Bookwyrm"
msgstr ""
#: bookwyrm/templates/feed/direct_messages.html:8 #: bookwyrm/templates/feed/direct_messages.html:8
#, python-format #, python-format
msgid "Direct Messages with <a href=\"%(path)s\">%(username)s</a>" msgid "Direct Messages with <a href=\"%(path)s\">%(username)s</a>"
@ -1684,11 +1703,6 @@ msgstr ""
msgid "Contact your admin or <a href='https://github.com/bookwyrm-social/bookwyrm/issues'>open an issue</a> if you are seeing unexpected failed items." msgid "Contact your admin or <a href='https://github.com/bookwyrm-social/bookwyrm/issues'>open an issue</a> if you are seeing unexpected failed items."
msgstr "" msgstr ""
#: bookwyrm/templates/landing/about.html:7 bookwyrm/templates/layout.html:230
#, python-format
msgid "About %(site_name)s"
msgstr ""
#: bookwyrm/templates/landing/about.html:10 #: bookwyrm/templates/landing/about.html:10
#: bookwyrm/templates/landing/about.html:20 #: bookwyrm/templates/landing/about.html:20
msgid "Code of Conduct" msgid "Code of Conduct"
@ -1860,10 +1874,6 @@ msgstr ""
msgid "Error posting status" msgid "Error posting status"
msgstr "" msgstr ""
#: bookwyrm/templates/layout.html:234
msgid "Contact site admin"
msgstr ""
#: bookwyrm/templates/layout.html:238 #: bookwyrm/templates/layout.html:238
msgid "Documentation" msgid "Documentation"
msgstr "" msgstr ""
@ -1930,6 +1940,21 @@ msgstr ""
msgid "Edit List" msgid "Edit List"
msgstr "" msgstr ""
#: bookwyrm/templates/lists/embed-list.html:7
#, python-format
msgid "%(list_name)s, a list by %(owner)s"
msgstr ""
#: bookwyrm/templates/lists/embed-list.html:17
#, python-format
msgid "on <a href=\"/\">%(site_name)s</a>"
msgstr ""
#: bookwyrm/templates/lists/embed-list.html:26
#: bookwyrm/templates/lists/list.html:29
msgid "This list is currently empty"
msgstr ""
#: bookwyrm/templates/lists/form.html:19 #: bookwyrm/templates/lists/form.html:19
msgid "List curation:" msgid "List curation:"
msgstr "" msgstr ""
@ -1995,10 +2020,6 @@ msgstr ""
msgid "You successfully added a book to this list!" msgid "You successfully added a book to this list!"
msgstr "" msgstr ""
#: bookwyrm/templates/lists/list.html:29
msgid "This list is currently empty"
msgstr ""
#: bookwyrm/templates/lists/list.html:67 #: bookwyrm/templates/lists/list.html:67
#, python-format #, python-format
msgid "Added by <a href=\"%(user_path)s\">%(username)s</a>" msgid "Added by <a href=\"%(user_path)s\">%(username)s</a>"
@ -2051,6 +2072,23 @@ msgstr ""
msgid "Suggest" msgid "Suggest"
msgstr "" msgstr ""
#: bookwyrm/templates/lists/list.html:191
msgid "Embed this list on a website"
msgstr ""
#: bookwyrm/templates/lists/list.html:193
msgid "Copy embed code"
msgstr ""
#: bookwyrm/templates/lists/list.html:193
msgid "Copied!"
msgstr ""
#: bookwyrm/templates/lists/list.html:193
#, python-format
msgid "%(list_name)s, a list by %(owner)s on %(site_name)s"
msgstr ""
#: bookwyrm/templates/lists/list_items.html:15 #: bookwyrm/templates/lists/list_items.html:15
msgid "Saved" msgid "Saved"
msgstr "" msgstr ""