Adds shelf views

This commit is contained in:
Mouse Reeve 2021-01-13 11:45:08 -08:00
parent 20e280e676
commit beeeaaaf39
8 changed files with 414 additions and 214 deletions

View file

@ -115,49 +115,6 @@ def handle_reject(follow_request):
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
shelve = models.ShelfBook(book=book, shelf=shelf, added_by=user)
shelve.save()
broadcast(user, shelve.to_add_activity(user))
def handle_unshelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete()
broadcast(user, activity)
def handle_reading_status(user, shelf, book, privacy):
''' post about a user reading a book '''
# tell the world about this cool thing that happened
try:
message = {
'to-read': 'wants to read',
'reading': 'started reading',
'read': 'finished reading'
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
return
status = create_generated_note(
user,
message,
mention_books=[book],
privacy=privacy
)
status.save()
broadcast(user, status.to_create_activity(user))
def handle_imported_book(user, item, include_reviews, privacy): def handle_imported_book(user, item, include_reviews, privacy):
''' process a goodreads csv and then post about it ''' ''' process a goodreads csv and then post about it '''
if isinstance(item.book, models.Work): if isinstance(item.book, models.Work):

View file

@ -0,0 +1,192 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.activitypub import ActivitypubResponse
class ShelfViews(TestCase):
''' tag views'''
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.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
)
self.shelf = models.Shelf.objects.create(
name='Test Shelf',
identifier='test-shelf',
user=self.local_user
)
def test_shelf_page(self):
''' there are so many views, this just makes sure it LOADS '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.first()
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
is_api.return_value = False
result = view(request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'shelf.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
is_api.return_value = True
result = view(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
request = self.factory.get('/?page=1')
request.user = self.local_user
with patch('bookwyrm.views.shelf.is_api_request') as is_api:
is_api.return_value = True
result = view(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, ActivitypubResponse)
self.assertEqual(result.status_code, 200)
def test_edit_shelf_privacy(self):
''' set name or privacy on shelf '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read')
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'unlisted',
'user': self.local_user.id,
'name': 'To Read',
})
request.user = self.local_user
view(request, self.local_user.username, shelf.id)
shelf.refresh_from_db()
self.assertEqual(shelf.privacy, 'unlisted')
def test_edit_shelf_name(self):
''' change the name of an editable shelf '''
view = views.Shelf.as_view()
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'public',
'user': self.local_user.id,
'name': 'cool name'
})
request.user = self.local_user
view(request, request.user.username, shelf.id)
shelf.refresh_from_db()
self.assertEqual(shelf.name, 'cool name')
self.assertEqual(shelf.identifier, 'testshelf-%d' % shelf.id)
def test_edit_shelf_name_not_editable(self):
''' can't change the name of an non-editable shelf '''
view = views.Shelf.as_view()
shelf = self.local_user.shelf_set.get(identifier='to-read')
self.assertEqual(shelf.privacy, 'public')
request = self.factory.post(
'', {
'privacy': 'public',
'user': self.local_user.id,
'name': 'cool name'
})
request.user = self.local_user
view(request, request.user.username, shelf.id)
self.assertEqual(shelf.name, 'To Read')
def test_handle_shelve(self):
''' shelve a book '''
request = self.factory.post('', {
'book': self.book.id,
'shelf': self.shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book)
def test_handle_shelve_to_read(self):
''' special behavior for the to-read shelf '''
shelf = models.Shelf.objects.get(identifier='to-read')
request = self.factory.post('', {
'book': self.book.id,
'shelf': shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_reading(self):
''' special behavior for the reading shelf '''
shelf = models.Shelf.objects.get(identifier='reading')
request = self.factory.post('', {
'book': self.book.id,
'shelf': shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_shelve_read(self):
''' special behavior for the read shelf '''
shelf = models.Shelf.objects.get(identifier='read')
request = self.factory.post('', {
'book': self.book.id,
'shelf': shelf.identifier
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.shelve(request)
# make sure the book is on the shelf
self.assertEqual(shelf.books.get(), self.book)
def test_handle_unshelve(self):
''' remove a book from a shelf '''
self.shelf.books.add(self.book)
self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1)
request = self.factory.post('', {
'book': self.book.id,
'shelf': self.shelf.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
views.unshelve(request)
self.assertEqual(self.shelf.books.count(), 0)

View file

@ -68,7 +68,7 @@ urlpatterns = [
# users # users
re_path(r'%s/?$' % user_path, views.User.as_view()), re_path(r'%s/?$' % user_path, views.User.as_view()),
re_path(r'%s\.json$' % user_path, views.User.as_view()), re_path(r'%s\.json$' % user_path, views.User.as_view()),
re_path(r'%s/shelves/?$' % user_path, vviews.user_shelves_page), re_path(r'%s/shelves/?$' % user_path, views.user_shelves_page),
re_path(r'%s/followers(.json)?/?$' % user_path, views.Followers.as_view()), re_path(r'%s/followers(.json)?/?$' % user_path, views.Followers.as_view()),
re_path(r'%s/following(.json)?/?$' % user_path, views.Following.as_view()), re_path(r'%s/following(.json)?/?$' % user_path, views.Following.as_view()),
re_path(r'^edit-profile/?$', views.EditUser.as_view()), re_path(r'^edit-profile/?$', views.EditUser.as_view()),
@ -106,10 +106,15 @@ urlpatterns = [
re_path(r'^tag/?$', views.AddTag.as_view()), re_path(r'^tag/?$', views.AddTag.as_view()),
re_path(r'^untag/?$', views.RemoveTag.as_view()), re_path(r'^untag/?$', views.RemoveTag.as_view()),
# shelf
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \ re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
user_path, vviews.shelf_page), user_path, views.Shelf.as_view()),
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \ re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % \
local_user_path, vviews.shelf_page), local_user_path, views.Shelf.as_view()),
re_path(r'^create-shelf/?$', views.create_shelf),
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', views.delete_shelf),
re_path(r'^shelve/?$', views.shelve),
re_path(r'^unshelve/?$', views.unshelve),
re_path(r'^search/?$', vviews.search), re_path(r'^search/?$', vviews.search),
@ -117,11 +122,6 @@ urlpatterns = [
re_path(r'^delete-readthrough/?$', actions.delete_readthrough), re_path(r'^delete-readthrough/?$', actions.delete_readthrough),
re_path(r'^create-readthrough/?$', actions.create_readthrough), re_path(r'^create-readthrough/?$', actions.create_readthrough),
re_path(r'^create-shelf/?$', actions.create_shelf),
re_path(r'^edit-shelf/(?P<shelf_id>\d+)?$', actions.edit_shelf),
re_path(r'^delete-shelf/(?P<shelf_id>\d+)?$', actions.delete_shelf),
re_path(r'^shelve/?$', actions.shelve),
re_path(r'^unshelve/?$', actions.unshelve),
re_path(r'^start-reading/(?P<book_id>\d+)/?$', actions.start_reading), re_path(r'^start-reading/(?P<book_id>\d+)/?$', actions.start_reading),
re_path(r'^finish-reading/(?P<book_id>\d+)/?$', actions.finish_reading), re_path(r'^finish-reading/(?P<book_id>\d+)/?$', actions.finish_reading),

View file

@ -8,95 +8,22 @@ from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from bookwyrm import forms, models, outgoing from bookwyrm import models, outgoing
from bookwyrm.vviews import get_user_from_username, get_edition
@login_required def get_edition(book_id):
@require_POST ''' look up a book in the db and return an edition '''
def create_shelf(request): book = models.Book.objects.select_subclasses().get(id=book_id)
''' user generated shelves ''' if isinstance(book, models.Work):
form = forms.ShelfForm(request.POST) book = book.get_default_edition()
if not form.is_valid(): return book
return redirect(request.headers.get('Referer', '/'))
shelf = form.save() def get_user_from_username(username):
return redirect('/user/%s/shelf/%s' % \ ''' helper function to resolve a localname or a username to a user '''
(request.user.localname, shelf.identifier)) # raises DoesNotExist if user is now found
@login_required
@require_POST
def edit_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user:
return HttpResponseBadRequest()
if not shelf.editable and request.POST.get('name') != shelf.name:
return HttpResponseBadRequest()
form = forms.ShelfForm(request.POST, instance=shelf)
if not form.is_valid():
return redirect(shelf.local_path)
shelf = form.save()
return redirect(shelf.local_path)
@login_required
@require_POST
def delete_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user or not shelf.editable:
return HttpResponseBadRequest()
shelf.delete()
return redirect('/user/%s/shelves' % request.user.localname)
@login_required
@require_POST
def shelve(request):
''' put a on a user's shelf '''
book = get_edition(request.POST['book'])
desired_shelf = models.Shelf.objects.filter(
identifier=request.POST['shelf'],
user=request.user
).first()
if request.POST.get('reshelve', True):
try: try:
current_shelf = models.Shelf.objects.get( return models.User.objects.get(localname=username)
user=request.user, except models.User.DoesNotExist:
edition=book return models.User.objects.get(username=username)
)
outgoing.handle_unshelve(request.user, book, current_shelf)
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
outgoing.handle_shelve(request.user, book, desired_shelf)
# post about "want to read" shelves
if desired_shelf.identifier == 'to-read':
outgoing.handle_reading_status(
request.user,
desired_shelf,
book,
privacy=desired_shelf.privacy
)
return redirect('/')
@login_required
@require_POST
def unshelve(request):
''' put a on a user's shelf '''
book = models.Edition.objects.get(id=request.POST['book'])
current_shelf = models.Shelf.objects.get(id=request.POST['shelf'])
outgoing.handle_unshelve(request.user, book, current_shelf)
return redirect(request.headers.get('Referer', '/'))
@login_required @login_required

View file

@ -13,3 +13,6 @@ from .books import Book, EditBook, Editions
from .books import upload_cover, add_description, switch_edition, resolve_book from .books import upload_cover, add_description, switch_edition, resolve_book
from .author import Author, EditAuthor from .author import Author, EditAuthor
from .tag import Tag, AddTag, RemoveTag from .tag import Tag, AddTag, RemoveTag
from .shelf import Shelf
from .shelf import user_shelves_page, create_shelf, delete_shelf
from .shelf import shelve, unshelve

View file

@ -4,7 +4,9 @@ from requests import HTTPError
from django.db.models import Q from django.db.models import Q
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
from bookwyrm.broadcast import broadcast
from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.status import create_generated_note
from bookwyrm.utils import regex from bookwyrm.utils import regex
@ -147,3 +149,25 @@ def get_edition(book_id):
return book return book
def handle_reading_status(user, shelf, book, privacy):
''' post about a user reading a book '''
# tell the world about this cool thing that happened
try:
message = {
'to-read': 'wants to read',
'reading': 'started reading',
'read': 'finished reading'
}[shelf.identifier]
except KeyError:
# it's a non-standard shelf, don't worry about it
return
status = create_generated_note(
user,
message,
mention_books=[book],
privacy=privacy
)
status.save()
broadcast(user, status.to_create_activity(user))

172
bookwyrm/views/shelf.py Normal file
View file

@ -0,0 +1,172 @@
''' shelf views'''
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.http import require_POST
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.broadcast import broadcast
from .helpers import is_api_request, get_edition, get_user_from_username
from .helpers import handle_reading_status
# pylint: disable= no-self-use
class Shelf(View):
''' shelf page '''
def get(self, request, username, shelf_identifier):
''' display a shelf '''
try:
user = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
if shelf_identifier:
shelf = user.shelf_set.get(identifier=shelf_identifier)
else:
shelf = user.shelf_set.first()
is_self = request.user == user
shelves = user.shelf_set
if not is_self:
follower = user.followers.filter(id=request.user.id).exists()
# make sure the user has permission to view the shelf
if shelf.privacy == 'direct' or \
(shelf.privacy == 'followers' and not follower):
return HttpResponseNotFound()
# only show other shelves that should be visible
if follower:
shelves = shelves.filter(privacy__in=['public', 'followers'])
else:
shelves = shelves.filter(privacy='public')
if is_api_request(request):
return ActivitypubResponse(shelf.to_activity(**request.GET))
books = models.ShelfBook.objects.filter(
added_by=user, shelf=shelf
).order_by('-updated_date').all()
data = {
'title': '%s\'s %s shelf' % (user.display_name, shelf.name),
'user': user,
'is_self': is_self,
'shelves': shelves.all(),
'shelf': shelf,
'books': [b.book for b in books],
}
return TemplateResponse(request, 'shelf.html', data)
@method_decorator(login_required, name='dispatch')
def post(self, request, username, shelf_id):
''' user generated shelves '''
if not request.user.username == username:
return HttpResponseBadRequest()
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user:
return HttpResponseBadRequest()
if not shelf.editable and request.POST.get('name') != shelf.name:
return HttpResponseBadRequest()
form = forms.ShelfForm(request.POST, instance=shelf)
if not form.is_valid():
return redirect(shelf.local_path)
shelf = form.save()
return redirect(shelf.local_path)
def user_shelves_page(request, username):
''' default shelf '''
return Shelf.as_view()(request, username, None)
@login_required
@require_POST
def create_shelf(request):
''' user generated shelves '''
form = forms.ShelfForm(request.POST)
if not form.is_valid():
return redirect(request.headers.get('Referer', '/'))
shelf = form.save()
return redirect('/user/%s/shelf/%s' % \
(request.user.localname, shelf.identifier))
@login_required
@require_POST
def delete_shelf(request, shelf_id):
''' user generated shelves '''
shelf = get_object_or_404(models.Shelf, id=shelf_id)
if request.user != shelf.user or not shelf.editable:
return HttpResponseBadRequest()
shelf.delete()
return redirect('/user/%s/shelves' % request.user.localname)
@login_required
@require_POST
def shelve(request):
''' put a on a user's shelf '''
book = get_edition(request.POST.get('book'))
desired_shelf = models.Shelf.objects.filter(
identifier=request.POST.get('shelf'),
user=request.user
).first()
if request.POST.get('reshelve', True):
try:
current_shelf = models.Shelf.objects.get(
user=request.user,
edition=book
)
handle_unshelve(request.user, book, current_shelf)
except models.Shelf.DoesNotExist:
# this just means it isn't currently on the user's shelves
pass
shelfbook = models.ShelfBook(
book=book, shelf=desired_shelf, added_by=request.user)
shelfbook.save()
broadcast(request.user, shelfbook.to_add_activity(request.user))
# post about "want to read" shelves
if desired_shelf.identifier == 'to-read':
handle_reading_status(
request.user,
desired_shelf,
book,
privacy=desired_shelf.privacy
)
return redirect('/')
@login_required
@require_POST
def unshelve(request):
''' put a on a user's shelf '''
book = models.Edition.objects.get(id=request.POST['book'])
current_shelf = models.Shelf.objects.get(id=request.POST['shelf'])
handle_unshelve(request.user, book, current_shelf)
return redirect(request.headers.get('Referer', '/'))
def handle_unshelve(user, book, shelf):
''' unshelve a book '''
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete()
broadcast(user, activity)

View file

@ -3,40 +3,21 @@ import re
from django.contrib.postgres.search import TrigramSimilarity from django.contrib.postgres.search import TrigramSimilarity
from django.db.models.functions import Greatest from django.db.models.functions import Greatest
from django.http import HttpResponseNotFound, JsonResponse from django.http import JsonResponse
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
from bookwyrm import outgoing from bookwyrm import outgoing
from bookwyrm import models from bookwyrm import models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager from bookwyrm.connectors import connector_manager
from bookwyrm.utils import regex 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)
if isinstance(book, models.Work):
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
try:
return models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return models.User.objects.get(username=username)
def is_api_request(request): def is_api_request(request):
''' check whether a request is asking for html or data ''' ''' check whether a request is asking for html or data '''
return 'json' in request.headers.get('Accept') or \ return 'json' in request.headers.get('Accept') or \
request.path[-5:] == '.json' request.path[-5:] == '.json'
def server_error_page(request): def server_error_page(request):
''' 500 errors ''' ''' 500 errors '''
return TemplateResponse( return TemplateResponse(
@ -84,59 +65,3 @@ def search(request):
'query': query, 'query': query,
} }
return TemplateResponse(request, 'search_results.html', data) return TemplateResponse(request, 'search_results.html', data)
@csrf_exempt
@require_GET
def user_shelves_page(request, username):
''' list of followers '''
return shelf_page(request, username, None)
@require_GET
def shelf_page(request, username, shelf_identifier):
''' display a shelf '''
try:
user = get_user_from_username(username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
if shelf_identifier:
shelf = user.shelf_set.get(identifier=shelf_identifier)
else:
shelf = user.shelf_set.first()
is_self = request.user == user
shelves = user.shelf_set
if not is_self:
follower = user.followers.filter(id=request.user.id).exists()
# make sure the user has permission to view the shelf
if shelf.privacy == 'direct' or \
(shelf.privacy == 'followers' and not follower):
return HttpResponseNotFound()
# only show other shelves that should be visible
if follower:
shelves = shelves.filter(privacy__in=['public', 'followers'])
else:
shelves = shelves.filter(privacy='public')
if is_api_request(request):
return ActivitypubResponse(shelf.to_activity(**request.GET))
books = models.ShelfBook.objects.filter(
added_by=user, shelf=shelf
).order_by('-updated_date').all()
data = {
'title': '%s\'s %s shelf' % (user.display_name, shelf.name),
'user': user,
'is_self': is_self,
'shelves': shelves.all(),
'shelf': shelf,
'books': [b.book for b in books],
}
return TemplateResponse(request, 'shelf.html', data)