''' views for pages you can go to in the application ''' 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, Max from django.db.models.functions import Greatest from django.http import HttpResponseNotFound, JsonResponse from django.core.exceptions import PermissionDenied from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_GET 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.tasks import app 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): ''' check whether a request is asking for html or data ''' 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 ''' return TemplateResponse( request, 'error.html', {'title': 'Oops!'}, status=500) def not_found_page(request, _): ''' 404s ''' return TemplateResponse( request, 'notfound.html', {'title': 'Not found'}, status=404) @require_GET def home(request): ''' this is the same as the feed on the home tab ''' if request.user.is_authenticated: return home_tab(request, 'home') return discover_page(request) @login_required @require_GET def home_tab(request, tab): ''' user's homepage with activity feed ''' try: page = int(request.GET.get('page', 1)) except ValueError: page = 1 suggested_books = get_suggested_books(request.user) if tab == 'home': activities = get_activity_feed( request.user, ['public', 'unlisted', 'followers'], following_only=True) elif tab == 'local': activities = get_activity_feed( request.user, ['public', 'followers'], local_only=True) else: 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, 'tab': tab, 'next': next_page, 'prev': prev_page, } return TemplateResponse(request, 'feed.html', data) def get_suggested_books(user, max_books=5): ''' helper to get a user's recent books ''' book_count = 0 preset_shelves = [ ('reading', max_books), ('read', 2), ('to-read', max_books) ] suggested_books = [] for (preset, shelf_max) in preset_shelves: limit = shelf_max if shelf_max < (max_books - book_count) \ else max_books - book_count shelf = user.shelf_set.get(identifier=preset) shelf_books = shelf.shelfbook_set.order_by( '-updated_date' ).all()[:limit] if not shelf_books: continue shelf_preview = { 'name': shelf.name, 'books': [s.book for s in shelf_books] } suggested_books.append(shelf_preview) book_count += len(shelf_preview['books']) return suggested_books @require_GET def discover_page(request): ''' tiled book activity page ''' books = models.Edition.objects.filter( review__published_date__isnull=False, review__user__local=True, review__privacy__in=['public', 'unlisted'], ).exclude( cover__exact='' ).annotate( Max('review__published_date') ).order_by('-review__published_date__max')[:6] ratings = {} for book in books: reviews = models.Review.objects.filter( book__in=book.parent_work.editions.all() ) reviews = get_activity_feed( request.user, ['public', 'unlisted'], queryset=reviews) ratings[book.id] = reviews.aggregate(Avg('rating'))['rating__avg'] data = { 'title': 'Discover', 'register_form': forms.RegisterForm(), 'books': list(set(books)), 'ratings': ratings } return TemplateResponse(request, 'discover.html', data) @login_required @require_GET def direct_messages_page(request, page=1): ''' like a feed but for dms only ''' 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, } return TemplateResponse(request, 'direct_messages.html', data) 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 ''' query = request.GET.get('q') min_confidence = request.GET.get('min_confidence', 0.1) if is_api_request(request): # only return local book results via json so we don't cause a cascade book_results = connector_manager.local_search( query, min_confidence=min_confidence) return JsonResponse([r.json() for r in book_results], safe=False) # use webfinger for mastodon style account@domain.com username if re.match(r'\B%s' % regex.full_username, query): outgoing.handle_remote_webfinger(query) # do a local user search user_results = models.User.objects.annotate( similarity=Greatest( TrigramSimilarity('username', query), TrigramSimilarity('localname', query), ) ).filter( similarity__gt=0.5, ).order_by('-similarity')[:10] book_results = connector_manager.search( query, min_confidence=min_confidence) data = { 'title': 'Search Results', 'book_results': book_results, 'user_results': user_results, 'query': query, } return TemplateResponse(request, 'search_results.html', data) @login_required @require_GET def import_page(request): ''' import history from goodreads ''' return TemplateResponse(request, 'import.html', { 'title': 'Import Books', 'import_form': forms.ImportForm(), 'jobs': models.ImportJob. objects.filter(user=request.user).order_by('-created_date'), }) @login_required @require_GET def import_status(request, job_id): ''' status of an import job ''' job = models.ImportJob.objects.get(id=job_id) if job.user != request.user: raise PermissionDenied task = app.AsyncResult(job.task_id) items = job.items.order_by('index').all() failed_items = [i for i in items if i.fail_reason] items = [i for i in items if not i.fail_reason] return TemplateResponse(request, 'import_status.html', { 'title': 'Import Status', 'job': job, 'items': items, 'failed_items': failed_items, 'task': task }) @require_GET def login_page(request): ''' authentication ''' if request.user.is_authenticated: return redirect('/') # send user to the login page data = { 'title': 'Login', 'login_form': forms.LoginForm(), 'register_form': forms.RegisterForm(), } return TemplateResponse(request, 'login.html', data) @require_GET def about_page(request): ''' more information about the instance ''' data = { 'title': 'About', } return TemplateResponse(request, 'about.html', data) @require_GET def password_reset_request(request): ''' invite management page ''' return TemplateResponse( request, 'password_reset_request.html', {'title': 'Reset Password'} ) @require_GET def password_reset(request, code): ''' endpoint for sending invites ''' if request.user.is_authenticated: return redirect('/') try: reset_code = models.PasswordReset.objects.get(code=code) if not reset_code.valid(): raise PermissionDenied except models.PasswordReset.DoesNotExist: raise PermissionDenied return TemplateResponse( request, 'password_reset.html', {'title': 'Reset Password', 'code': reset_code.code} ) @require_GET def invite_page(request, code): ''' endpoint for sending invites ''' if request.user.is_authenticated: return redirect('/') invite = get_object_or_404(models.SiteInvite, code=code) data = { 'title': 'Join', 'register_form': forms.RegisterForm(), 'invite': invite, 'valid': invite.valid() if invite else True, } return TemplateResponse(request, 'invite.html', data) @login_required @permission_required('bookwyrm.create_invites', raise_exception=True) @require_GET def manage_invites(request): ''' invite management page ''' data = { 'title': 'Invitations', 'invites': models.SiteInvite.objects.filter( user=request.user).order_by('-created_date'), 'form': forms.CreateInviteForm(), } return TemplateResponse(request, 'manage_invites.html', data) @login_required @require_GET def notifications_page(request): ''' list notitications ''' notifications = request.user.notification_set.all() \ .order_by('-created_date') unread = [n.id for n in notifications.filter(read=False)] data = { 'title': 'Notifications', 'notifications': notifications, 'unread': unread, } notifications.update(read=True) return TemplateResponse(request, 'notifications.html', data) @csrf_exempt @require_GET def user_page(request, username): ''' profile page for a user ''' try: user = get_user_from_username(username) except models.User.DoesNotExist: return HttpResponseNotFound() if is_api_request(request): # we have a json request return ActivitypubResponse(user.to_activity()) # otherwise we're at a UI view try: page = int(request.GET.get('page', 1)) except ValueError: page = 1 shelf_preview = [] # only show other shelves that should be visible shelves = user.shelf_set is_self = request.user.id == user.id if not is_self: follower = user.followers.filter(id=request.user.id).exists() if follower: shelves = shelves.filter(privacy__in=['public', 'followers']) else: shelves = shelves.filter(privacy='public') for user_shelf in shelves.all(): if not user_shelf.books.count(): continue shelf_preview.append({ 'name': user_shelf.name, 'local_path': user_shelf.local_path, 'books': user_shelf.books.all()[:3], 'size': user_shelf.books.count(), }) if len(shelf_preview) > 2: break # user's posts activities = get_activity_feed( request.user, ['public', 'unlisted', 'followers'], 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, } return TemplateResponse(request, 'user.html', data) @csrf_exempt @require_GET def followers_page(request, username): ''' list of followers ''' try: user = get_user_from_username(username) except models.User.DoesNotExist: return HttpResponseNotFound() if is_api_request(request): return ActivitypubResponse(user.to_followers_activity(**request.GET)) data = { 'title': '%s: followers' % user.name, 'user': user, 'is_self': request.user.id == user.id, 'followers': user.followers.all(), } return TemplateResponse(request, 'followers.html', data) @csrf_exempt @require_GET def following_page(request, username): ''' list of followers ''' try: user = get_user_from_username(username) except models.User.DoesNotExist: return HttpResponseNotFound() if is_api_request(request): return ActivitypubResponse(user.to_following_activity(**request.GET)) data = { 'title': '%s: following' % user.name, 'user': user, 'is_self': request.user.id == user.id, 'following': user.following.all(), } return TemplateResponse(request, 'following.html', data) @csrf_exempt @require_GET def status_page(request, username, status_id): ''' display a particular status (and replies, etc) ''' try: user = get_user_from_username(username) status = models.Status.objects.select_subclasses().get(id=status_id) except ValueError: return HttpResponseNotFound() # the url should have the poster's username in it if user != status.user: return HttpResponseNotFound() # make sure the user is authorized to see the status if not status_visible_to_user(request.user, status): return HttpResponseNotFound() if is_api_request(request): return ActivitypubResponse( status.to_activity(pure=not is_bookworm_request(request))) data = { 'title': 'Status by %s' % user.username, 'status': status, } return TemplateResponse(request, 'status.html', data) def status_visible_to_user(viewer, status): ''' is a user authorized to view a status? ''' if viewer == status.user or status.privacy in ['public', 'unlisted']: return True if status.privacy == 'followers' and \ status.user.followers.filter(id=viewer.id).first(): return True if status.privacy == 'direct' and \ status.mention_users.filter(id=viewer.id).first(): return True return False @csrf_exempt @require_GET def replies_page(request, username, status_id): ''' ordered collection of replies to a status ''' if not is_api_request(request): return status_page(request, username, status_id) status = models.Status.objects.get(id=status_id) if status.user.localname != username: return HttpResponseNotFound() return ActivitypubResponse(status.to_replies(**request.GET)) @login_required @require_GET def edit_profile_page(request): ''' profile page for a user ''' user = request.user form = forms.EditUserForm(instance=request.user) data = { 'title': 'Edit profile', 'form': form, 'user': user, } return TemplateResponse(request, 'edit_user.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') for readthrough in readthroughs: readthrough.progress_updates = \ readthrough.progressupdate_set.all().order_by('-updated_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, 'show_progress': ('showprogress' in request.GET), '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 def edit_author_page(request, author_id): ''' info about a book ''' author = get_object_or_404(models.Author, id=author_id) data = { 'title': 'Edit Author', 'author': author, 'form': forms.AuthorForm(instance=author) } 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 ''' author = get_object_or_404(models.Author, id=author_id) if is_api_request(request): return ActivitypubResponse(author.to_activity()) books = models.Work.objects.filter( Q(authors=author) | Q(editions__authors=author)).distinct() data = { 'title': author.name, 'author': author, 'books': [b.get_default_edition() for b in books], } return TemplateResponse(request, 'author.html', data) @require_GET def tag_page(request, tag_id): ''' books related to a tag ''' tag_obj = models.Tag.objects.filter(identifier=tag_id).first() if not tag_obj: return HttpResponseNotFound() if is_api_request(request): return ActivitypubResponse(tag_obj.to_activity(**request.GET)) books = models.Edition.objects.filter( usertag__tag__identifier=tag_id ).distinct() data = { 'title': tag_obj.name, 'books': books, 'tag': tag_obj, } return TemplateResponse(request, 'tag.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)