Adds book class view and re-works pagination

This commit is contained in:
Mouse Reeve 2021-01-13 09:42:54 -08:00
parent cf2b9937c6
commit 56e3e98bc1
16 changed files with 197 additions and 439 deletions

View file

@ -223,7 +223,7 @@
</div>
</div>
<div class="block">
<div class="block" id="reviews">
{% for review in reviews %}
<div class="block">
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %}
@ -231,25 +231,28 @@
{% endfor %}
<div class="block is-flex is-flex-wrap-wrap">
{% for rating in ratings %}
<div class="block mr-5">
<div class="media">
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
<div class="media-content">
<div>
{% include 'snippets/username.html' with user=rating.user %}
</div>
<div class="field is-grouped mb-0">
<div>rated it</div>
{% include 'snippets/stars.html' with rating=rating.rating %}
</div>
<div>
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
{% for rating in ratings %}
<div class="block mr-5">
<div class="media">
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
<div class="media-content">
<div>
{% include 'snippets/username.html' with user=rating.user %}
</div>
<div class="field is-grouped mb-0">
<div>rated it</div>
{% include 'snippets/stars.html' with rating=rating.rating %}
</div>
<div>
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
</div>
</div>

View file

@ -13,25 +13,7 @@
</div>
{% endfor %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
{% include 'snippets/pagination.html' with page=activities path="direct-messages" %}
</div>
{% endblock %}

View file

@ -27,7 +27,7 @@
</div>
{% endif %}
<form class="block" name="edit-book" action="/edit-book/{{ book.id }}" method="post" enctype="multipart/form-data">
<form class="block" name="edit-book" action="{{ book.local_path }}/edit" method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<div class="columns">

View file

@ -94,25 +94,7 @@
</div>
{% endfor %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
{% include 'snippets/pagination.html' with page=activities path='/'|add:tab anchor="#feed" %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,19 @@
<nav class="pagination" role="navigation" aria-label="pagination">
{% if page.has_previous %}
<p class="pagination-previous">
<a href="{{ path }}?page={{ page.previous_page_number }}{{ anchor }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if page.has_next %}
<p class="pagination-next">
<a href="{{ path }}?page={{ page.next_page_number }}{{ anchor }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>

View file

@ -45,7 +45,7 @@
<h2 class="title">User Activity</h2>
</div>
{% for activity in activities %}
<div class="block">
<div class="block" id="feed">
{% include 'snippets/status.html' with status=activity %}
</div>
{% endfor %}
@ -55,25 +55,7 @@
</div>
{% endif %}
<nav class="pagination" role="navigation" aria-label="pagination">
{% if prev %}
<p class="pagination-previous">
<a href="{{ prev }}">
<span class="icon icon-arrow-left"></span>
Previous
</a>
</p>
{% endif %}
{% if next %}
<p class="pagination-next">
<a href="{{ next }}">
Next
<span class="icon icon-arrow-right"></span>
</a>
</p>
{% endif %}
</nav>
{% include 'snippets/pagination.html' with page=activities path=user.local_path anchor="#feed" %}
</div>
{% endblock %}

View file

@ -0,0 +1,127 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import forms, models, views
from bookwyrm.activitypub import ActivitypubResponse
class InteractionViews(TestCase):
''' viewing and creating statuses '''
def setUp(self):
''' we need basic test data and mocks '''
self.factory = RequestFactory()
self.local_user = models.User.objects.create_user(
'mouse@local.com', 'mouse@mouse.com', 'mouseword',
local=True, localname='mouse',
remote_id='https://example.com/users/mouse',
)
self.group = Group.objects.create(name='editor')
self.group.permissions.add(
Permission.objects.create(
name='edit_book',
codename='edit_book',
content_type=ContentType.objects.get_for_model(models.User)).id
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Example Edition',
remote_id='https://example.com/book/1',
parent_work=self.work
)
def test_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Book.as_view()
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'book.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = True
result = view(request, self.book.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.EditBook.as_view()
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = view(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_book.html')
self.assertEqual(result.status_code, 200)
def test_edit_book(self):
''' lets a user edit a book '''
view = views.EditBook.as_view()
self.local_user.groups.add(self.group)
form = forms.EditionForm(instance=self.book)
form.data['title'] = 'New Title'
form.data['last_edited_by'] = self.local_user.id
request = self.factory.post('', form.data)
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
view(request, self.book.id)
self.book.refresh_from_db()
self.assertEqual(self.book.title, 'New Title')
def test_switch_edition(self):
''' updates user's relationships to a book '''
work = models.Work.objects.create(title='test work')
edition1 = models.Edition.objects.create(
title='first ed', parent_work=work)
edition2 = models.Edition.objects.create(
title='second ed', parent_work=work)
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
shelf.books.add(edition1)
models.ReadThrough.objects.create(
user=self.local_user, book=edition1)
self.assertEqual(models.ShelfBook.objects.get().book, edition1)
self.assertEqual(models.ReadThrough.objects.get().book, edition1)
request = self.factory.post('', {
'edition': edition2.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.switch_edition(request)
self.assertEqual(models.ShelfBook.objects.get().book, edition2)
self.assertEqual(models.ReadThrough.objects.get().book, edition2)
def test_editions_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Editions.as_view()
request = self.factory.get('')
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.work.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'editions.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.books.is_api_request') as is_api:
is_api.return_value = True
result = view(request, self.work.id)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)

View file

@ -88,10 +88,16 @@ urlpatterns = [
re_path(r'^unboost/(?P<status_id>\d+)/?$', views.Unboost.as_view()),
# books
re_path(r'%s(.json)?/?$' % book_path, vviews.book_page),
re_path(r'%s/edit/?$' % book_path, vviews.edit_book_page),
re_path(r'%s(.json)?/?$' % book_path, views.Book.as_view()),
re_path(r'%s/edit/?$' % book_path, views.EditBook.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'^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),
re_path(r'^author/(?P<author_id>[\w\-]+)/edit/?$', vviews.edit_author_page),
re_path(r'%s/editions(.json)?/?$' % book_path, vviews.editions_page),
re_path(r'^tag/?$', actions.tag),
re_path(r'^untag/?$', actions.untag),
@ -105,15 +111,8 @@ urlpatterns = [
re_path(r'^search/?$', vviews.search),
# internal action endpoints
re_path(r'^resolve-book/?$', actions.resolve_book),
re_path(r'^edit-book/(?P<book_id>\d+)/?$', actions.edit_book),
re_path(r'^upload-cover/(?P<book_id>\d+)/?$', actions.upload_cover),
re_path(r'^add-description/(?P<book_id>\d+)/?$', actions.add_description),
re_path(r'^edit-author/(?P<author_id>\d+)/?$', actions.edit_author),
re_path(r'^switch-edition/?$', actions.switch_edition),
re_path(r'^edit-readthrough/?$', actions.edit_readthrough),
re_path(r'^delete-readthrough/?$', actions.delete_readthrough),
re_path(r'^create-readthrough/?$', actions.create_readthrough),

View file

@ -3,7 +3,6 @@ import dateutil.parser
from dateutil.parser import ParserError
from django.contrib.auth.decorators import login_required, permission_required
from django.db import transaction
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
@ -11,108 +10,9 @@ from django.utils import timezone
from django.views.decorators.http import require_POST
from bookwyrm import forms, models, outgoing
from bookwyrm.connectors import connector_manager
from bookwyrm.broadcast import broadcast
from bookwyrm.vviews import get_user_from_username, get_edition
@require_POST
def resolve_book(request):
''' figure out the local path to a book from a remote_id '''
remote_id = request.POST.get('remote_id')
connector = connector_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(remote_id)
return redirect('/book/%d' % book.id)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_POST
def edit_book(request, book_id):
''' edit a book cool '''
book = get_object_or_404(models.Edition, id=book_id)
form = forms.EditionForm(request.POST, request.FILES, instance=book)
if not form.is_valid():
data = {
'title': 'Edit Book',
'book': book,
'form': form
}
return TemplateResponse(request, 'edit_book.html', data)
book = form.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@login_required
@require_POST
@transaction.atomic
def switch_edition(request):
''' switch your copy of a book to a different edition '''
edition_id = request.POST.get('edition')
new_edition = get_object_or_404(models.Edition, id=edition_id)
shelfbooks = models.ShelfBook.objects.filter(
book__parent_work=new_edition.parent_work,
shelf__user=request.user
)
for shelfbook in shelfbooks.all():
broadcast(request.user, shelfbook.to_remove_activity(request.user))
shelfbook.book = new_edition
shelfbook.save()
broadcast(request.user, shelfbook.to_add_activity(request.user))
readthroughs = models.ReadThrough.objects.filter(
book__parent_work=new_edition.parent_work,
user=request.user
)
for readthrough in readthroughs.all():
readthrough.book = new_edition
readthrough.save()
return redirect('/book/%d' % new_edition.id)
@login_required
@require_POST
def upload_cover(request, book_id):
''' upload a new cover '''
book = get_object_or_404(models.Edition, id=book_id)
form = forms.CoverForm(request.POST, request.FILES, instance=book)
if not form.is_valid():
return redirect('/book/%d' % book.id)
book.cover = form.files['cover']
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@login_required
@require_POST
@permission_required('bookwyrm.edit_book', raise_exception=True)
def add_description(request, book_id):
''' upload a new cover '''
if not request.method == 'POST':
return redirect('/')
book = get_object_or_404(models.Edition, id=book_id)
description = request.POST.get('description')
book.description = description
book.save()
broadcast(request.user, book.to_update_activity(request.user))
return redirect('/book/%s' % book.id)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_POST
@ -378,33 +278,6 @@ def untag(request):
return redirect('/book/%s' % book_id)
@login_required
@require_POST
def unfavorite(request, status_id):
''' like a status '''
status = models.Status.objects.get(id=status_id)
outgoing.handle_unfavorite(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def boost(request, status_id):
''' boost a status '''
status = models.Status.objects.get(id=status_id)
outgoing.handle_boost(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def unboost(request, status_id):
''' boost a status '''
status = models.Status.objects.get(id=status_id)
outgoing.handle_unboost(request.user, status)
return redirect(request.headers.get('Referer', '/'))
@login_required
@require_POST
def follow(request):

View file

@ -9,3 +9,5 @@ from .import_data import Import, ImportStatus
from .user import User, EditUser, Followers, Following
from .status import Status, Replies, CreateStatus, DeleteStatus
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .books import Book, EditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book

View file

@ -18,19 +18,9 @@ class DirectMessage(View):
activities = get_activity_feed(request.user, 'direct')
paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page)
prev_page = next_page = None
if activity_page.has_next():
next_page = '/direct-message/?page=%d#feed' % \
activity_page.next_page_number()
if activity_page.has_previous():
prev_page = '/direct-messages/?page=%d#feed' % \
activity_page.previous_page_number()
data = {
'title': 'Direct Messages',
'user': request.user,
'activities': activity_page.object_list,
'next': next_page,
'prev': prev_page,
'activities': activity_page,
}
return TemplateResponse(request, 'direct_messages.html', data)

View file

@ -137,3 +137,13 @@ def handle_remote_webfinger(query):
except KeyError:
return None
return user
def get_edition(book_id):
''' look up a book in the db and return an edition '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if isinstance(book, models.Work):
book = book.get_default_edition()
return book

View file

@ -105,7 +105,6 @@ class Boost(View):
return redirect(request.headers.get('Referer', '/'))
@method_decorator(login_required, name='dispatch')
class Unboost(View):
''' boost a status '''

View file

@ -86,23 +86,12 @@ class Feed(View):
activities = get_activity_feed(
request.user, ['public', 'followers'])
paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page)
prev_page = next_page = None
if activity_page.has_next():
next_page = '/%s/?page=%d#feed' % \
(tab, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '/%s/?page=%d#feed' % \
(tab, activity_page.previous_page_number())
data = {
'title': 'Updates Feed',
'user': request.user,
'suggested_books': suggested_books,
'activities': activity_page.object_list,
'activities': paginated.page(page),
'tab': tab,
'next': next_page,
'prev': prev_page,
}
return TemplateResponse(request, 'feed.html', data)

View file

@ -70,24 +70,13 @@ class User(View):
queryset=models.Status.objects.filter(user=user)
)
paginated = Paginator(activities, PAGE_LENGTH)
activity_page = paginated.page(page)
prev_page = next_page = None
if activity_page.has_next():
next_page = '/user/%s/?page=%d' % \
(username, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '/user/%s/?page=%d' % \
(username, activity_page.previous_page_number())
data = {
'title': user.name,
'user': user,
'is_self': is_self,
'shelves': shelf_preview,
'shelf_count': shelves.count(),
'activities': activity_page.object_list,
'next': next_page,
'prev': prev_page,
'activities': paginated.page(page),
}
return TemplateResponse(request, 'user.html', data)

View file

@ -3,8 +3,7 @@ import re
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import TrigramSimilarity
from django.core.paginator import Paginator
from django.db.models import Avg, Q
from django.db.models import Q
from django.db.models.functions import Greatest
from django.http import HttpResponseNotFound, JsonResponse
from django.shortcuts import get_object_or_404
@ -16,10 +15,8 @@ from bookwyrm import outgoing
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.utils import regex
def get_edition(book_id):
''' look up a book in the db and return an edition '''
book = models.Book.objects.select_subclasses().get(id=book_id)
@ -27,6 +24,8 @@ def get_edition(book_id):
book = book.get_default_edition()
return book
def get_user_from_username(username):
''' helper function to resolve a localname or a username to a user '''
# raises DoesNotExist if user is now found
@ -41,14 +40,6 @@ def is_api_request(request):
return 'json' in request.headers.get('Accept') or \
request.path[-5:] == '.json'
def is_bookworm_request(request):
''' check if the request is coming from another bookworm instance '''
user_agent = request.headers.get('User-Agent')
if user_agent is None or \
re.search(regex.bookwyrm_user_agent, user_agent) is None:
return False
return True
def server_error_page(request):
''' 500 errors '''
@ -62,64 +53,6 @@ def not_found_page(request, _):
request, 'notfound.html', {'title': 'Not found'}, status=404)
def get_activity_feed(
user, privacy, local_only=False, following_only=False,
queryset=models.Status.objects):
''' get a filtered queryset of statuses '''
privacy = privacy if isinstance(privacy, list) else [privacy]
# if we're looking at Status, we need this. We don't if it's Comment
if hasattr(queryset, 'select_subclasses'):
queryset = queryset.select_subclasses()
# exclude deleted
queryset = queryset.exclude(deleted=True).order_by('-published_date')
# you can't see followers only or direct messages if you're not logged in
if user.is_anonymous:
privacy = [p for p in privacy if not p in ['followers', 'direct']]
# filter to only privided privacy levels
queryset = queryset.filter(privacy__in=privacy)
# only include statuses the user follows
if following_only:
queryset = queryset.exclude(
~Q(# remove everythign except
Q(user__in=user.following.all()) | # user follwoing
Q(user=user) |# is self
Q(mention_users=user)# mentions user
),
)
# exclude followers-only statuses the user doesn't follow
elif 'followers' in privacy:
queryset = queryset.exclude(
~Q(# user isn't following and it isn't their own status
Q(user__in=user.following.all()) | Q(user=user)
),
privacy='followers' # and the status is followers only
)
# exclude direct messages not intended for the user
if 'direct' in privacy:
queryset = queryset.exclude(
~Q(
Q(user=user) | Q(mention_users=user)
), privacy='direct'
)
# filter for only local status
if local_only:
queryset = queryset.filter(user__local=True)
# remove statuses that have boosts in the same queryset
try:
queryset = queryset.filter(~Q(boosters__in=queryset))
except ValueError:
pass
return queryset
@require_GET
def search(request):
''' that search bar up top '''
@ -157,111 +90,6 @@ def search(request):
return TemplateResponse(request, 'search_results.html', data)
@require_GET
def book_page(request, book_id):
''' info about a book '''
try:
page = int(request.GET.get('page', 1))
except ValueError:
page = 1
try:
book = models.Book.objects.select_subclasses().get(id=book_id)
except models.Book.DoesNotExist:
return HttpResponseNotFound()
if is_api_request(request):
return ActivitypubResponse(book.to_activity())
if isinstance(book, models.Work):
book = book.get_default_edition()
if not book:
return HttpResponseNotFound()
work = book.parent_work
if not work:
return HttpResponseNotFound()
reviews = models.Review.objects.filter(
book__in=work.editions.all(),
)
# all reviews for the book
reviews = get_activity_feed(
request.user,
['public', 'unlisted', 'followers', 'direct'],
queryset=reviews
)
# the reviews to show
paginated = Paginator(reviews.exclude(
Q(content__isnull=True) | Q(content='')
), PAGE_LENGTH)
reviews_page = paginated.page(page)
prev_page = next_page = None
if reviews_page.has_next():
next_page = '/book/%d/?page=%d' % \
(book_id, reviews_page.next_page_number())
if reviews_page.has_previous():
prev_page = '/book/%s/?page=%d' % \
(book_id, reviews_page.previous_page_number())
user_tags = readthroughs = user_shelves = other_edition_shelves = []
if request.user.is_authenticated:
user_tags = models.UserTag.objects.filter(
book=book, user=request.user
).values_list('tag__identifier', flat=True)
readthroughs = models.ReadThrough.objects.filter(
user=request.user,
book=book,
).order_by('start_date')
user_shelves = models.ShelfBook.objects.filter(
added_by=request.user, book=book
)
other_edition_shelves = models.ShelfBook.objects.filter(
~Q(book=book),
added_by=request.user,
book__parent_work=book.parent_work,
)
data = {
'title': book.title,
'book': book,
'reviews': reviews_page,
'review_count': reviews.count(),
'ratings': reviews.filter(Q(content__isnull=True) | Q(content='')),
'rating': reviews.aggregate(Avg('rating'))['rating__avg'],
'tags': models.UserTag.objects.filter(book=book),
'user_tags': user_tags,
'user_shelves': user_shelves,
'other_edition_shelves': other_edition_shelves,
'readthroughs': readthroughs,
'path': '/book/%s' % book_id,
'next': next_page,
'prev': prev_page,
}
return TemplateResponse(request, 'book.html', data)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_GET
def edit_book_page(request, book_id):
''' info about a book '''
book = get_edition(book_id)
if not book.description:
book.description = book.parent_work.description
data = {
'title': 'Edit Book',
'book': book,
'form': forms.EditionForm(instance=book)
}
return TemplateResponse(request, 'edit_book.html', data)
@login_required
@permission_required('bookwyrm.edit_book', raise_exception=True)
@require_GET
@ -276,22 +104,6 @@ def edit_author_page(request, author_id):
return TemplateResponse(request, 'edit_author.html', data)
@require_GET
def editions_page(request, book_id):
''' list of editions of a book '''
work = get_object_or_404(models.Work, id=book_id)
if is_api_request(request):
return ActivitypubResponse(work.to_edition_list(**request.GET))
data = {
'title': 'Editions of %s' % work.title,
'editions': work.editions.order_by('-edition_rank').all(),
'work': work,
}
return TemplateResponse(request, 'editions.html', data)
@require_GET
def author_page(request, author_id):
''' landing page for an author '''