diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index 5c982764b..b19994eda 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -2,10 +2,11 @@ import csv import logging -from bookwyrm import outgoing -from bookwyrm.tasks import app +from bookwyrm import models +from bookwyrm.broadcast import broadcast from bookwyrm.models import ImportJob, ImportItem from bookwyrm.status import create_notification +from bookwyrm.tasks import app logger = logging.getLogger(__name__) @@ -62,7 +63,7 @@ def import_data(job_id): item.save() # shelves book and handles reviews - outgoing.handle_imported_book( + handle_imported_book( job.user, item, job.include_reviews, job.privacy) else: item.fail_reason = 'Could not find a match for book' @@ -71,3 +72,57 @@ def import_data(job_id): create_notification(job.user, 'IMPORT', related_import=job) job.complete = True job.save() + + +def handle_imported_book(user, item, include_reviews, privacy): + ''' process a goodreads csv and then post about it ''' + if isinstance(item.book, models.Work): + item.book = item.book.default_edition + if not item.book: + return + + existing_shelf = models.ShelfBook.objects.filter( + book=item.book, added_by=user).exists() + + # shelve the book if it hasn't been shelved already + if item.shelf and not existing_shelf: + desired_shelf = models.Shelf.objects.get( + identifier=item.shelf, + user=user + ) + shelf_book = models.ShelfBook.objects.create( + book=item.book, shelf=desired_shelf, added_by=user) + broadcast(user, shelf_book.to_add_activity(user), privacy=privacy) + + for read in item.reads: + # check for an existing readthrough with the same dates + if models.ReadThrough.objects.filter( + user=user, book=item.book, + start_date=read.start_date, + finish_date=read.finish_date + ).exists(): + continue + read.book = item.book + read.user = user + read.save() + + if include_reviews and (item.rating or item.review): + review_title = 'Review of {!r} on Goodreads'.format( + item.book.title, + ) if item.review else '' + + # we don't know the publication date of the review, + # but "now" is a bad guess + published_date_guess = item.date_read or item.date_added + review = models.Review.objects.create( + user=user, + book=item.book, + name=review_title, + content=item.review, + rating=item.rating, + published_date=published_date_guess, + privacy=privacy, + ) + # we don't need to send out pure activities because non-bookwyrm + # instances don't need this data + broadcast(user, review.to_create_activity(user), privacy=privacy) diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 920e99c5e..9653c5d23 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -9,7 +9,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST import requests -from bookwyrm import activitypub, models, outgoing +from bookwyrm import activitypub, models, views from bookwyrm import status as status_builder from bookwyrm.tasks import app from bookwyrm.signatures import Signature @@ -133,7 +133,7 @@ def handle_follow(activity): related_user=relationship.user_subject ) if not manually_approves: - outgoing.handle_accept(relationship) + views.handle_accept(relationship) @app.task diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py deleted file mode 100644 index 2a62fd047..000000000 --- a/bookwyrm/outgoing.py +++ /dev/null @@ -1,414 +0,0 @@ -''' handles all the activity coming out of the server ''' -import re - -from django.db import IntegrityError, transaction -from django.http import JsonResponse -from django.shortcuts import get_object_or_404 -from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_GET -from markdown import markdown -from requests import HTTPError - -from bookwyrm import activitypub -from bookwyrm import models -from bookwyrm.connectors import get_data, ConnectorException -from bookwyrm.broadcast import broadcast -from bookwyrm.sanitize_html import InputHtmlParser -from bookwyrm.status import create_notification -from bookwyrm.status import create_generated_note -from bookwyrm.status import delete_status -from bookwyrm.settings import DOMAIN -from bookwyrm.utils import regex - - -@csrf_exempt -@require_GET -def outbox(request, username): - ''' outbox for the requested user ''' - user = get_object_or_404(models.User, localname=username) - filter_type = request.GET.get('type') - if filter_type not in models.status_models: - filter_type = None - - return JsonResponse( - user.to_outbox(**request.GET, filter_type=filter_type), - encoder=activitypub.ActivityEncoder - ) - - -def handle_remote_webfinger(query): - ''' webfingerin' other servers ''' - user = None - - # usernames could be @user@domain or user@domain - if not query: - return None - - if query[0] == '@': - query = query[1:] - - try: - domain = query.split('@')[1] - except IndexError: - return None - - try: - user = models.User.objects.get(username=query) - except models.User.DoesNotExist: - url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \ - (domain, query) - try: - data = get_data(url) - except (ConnectorException, HTTPError): - return None - - for link in data.get('links'): - if link.get('rel') == 'self': - try: - user = activitypub.resolve_remote_id( - models.User, link['href'] - ) - except KeyError: - return None - return user - - -def handle_follow(user, to_follow): - ''' someone local wants to follow someone ''' - relationship, _ = models.UserFollowRequest.objects.get_or_create( - user_subject=user, - user_object=to_follow, - ) - activity = relationship.to_activity() - broadcast(user, activity, privacy='direct', direct_recipients=[to_follow]) - - -def handle_unfollow(user, to_unfollow): - ''' someone local wants to follow someone ''' - relationship = models.UserFollows.objects.get( - user_subject=user, - user_object=to_unfollow - ) - activity = relationship.to_undo_activity(user) - broadcast(user, activity, privacy='direct', direct_recipients=[to_unfollow]) - to_unfollow.followers.remove(user) - - -def handle_accept(follow_request): - ''' send an acceptance message to a follow request ''' - user = follow_request.user_subject - to_follow = follow_request.user_object - with transaction.atomic(): - relationship = models.UserFollows.from_request(follow_request) - follow_request.delete() - relationship.save() - - activity = relationship.to_accept_activity() - broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) - - -def handle_reject(follow_request): - ''' a local user who managed follows rejects a follow request ''' - user = follow_request.user_subject - to_follow = follow_request.user_object - activity = follow_request.to_reject_activity() - follow_request.delete() - 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): - ''' process a goodreads csv and then post about it ''' - if isinstance(item.book, models.Work): - item.book = item.book.default_edition - if not item.book: - return - - existing_shelf = models.ShelfBook.objects.filter( - book=item.book, added_by=user).exists() - - # shelve the book if it hasn't been shelved already - if item.shelf and not existing_shelf: - desired_shelf = models.Shelf.objects.get( - identifier=item.shelf, - user=user - ) - shelf_book = models.ShelfBook.objects.create( - book=item.book, shelf=desired_shelf, added_by=user) - broadcast(user, shelf_book.to_add_activity(user), privacy=privacy) - - for read in item.reads: - # check for an existing readthrough with the same dates - if models.ReadThrough.objects.filter( - user=user, book=item.book, - start_date=read.start_date, - finish_date=read.finish_date - ).exists(): - continue - read.book = item.book - read.user = user - read.save() - - if include_reviews and (item.rating or item.review): - review_title = 'Review of {!r} on Goodreads'.format( - item.book.title, - ) if item.review else '' - - # we don't know the publication date of the review, - # but "now" is a bad guess - published_date_guess = item.date_read or item.date_added - review = models.Review.objects.create( - user=user, - book=item.book, - name=review_title, - content=item.review, - rating=item.rating, - published_date=published_date_guess, - privacy=privacy, - ) - # we don't need to send out pure activities because non-bookwyrm - # instances don't need this data - broadcast(user, review.to_create_activity(user), privacy=privacy) - - -def handle_delete_status(user, status): - ''' delete a status and broadcast deletion to other servers ''' - delete_status(status) - broadcast(user, status.to_delete_activity(user)) - - -def handle_status(user, form): - ''' generic handler for statuses ''' - status = form.save(commit=False) - if not status.sensitive and status.content_warning: - # the cw text field remains populated when you click "remove" - status.content_warning = None - status.save() - - # inspect the text for user tags - content = status.content - for (mention_text, mention_user) in find_mentions(content): - # add them to status mentions fk - status.mention_users.add(mention_user) - - # turn the mention into a link - content = re.sub( - r'%s([^@]|$)' % mention_text, - r'%s\g<1>' % \ - (mention_user.remote_id, mention_text), - content) - - # add reply parent to mentions and notify - if status.reply_parent: - status.mention_users.add(status.reply_parent.user) - for mention_user in status.reply_parent.mention_users.all(): - status.mention_users.add(mention_user) - - if status.reply_parent.user.local: - create_notification( - status.reply_parent.user, - 'REPLY', - related_user=user, - related_status=status - ) - - # deduplicate mentions - status.mention_users.set(set(status.mention_users.all())) - # create mention notifications - for mention_user in status.mention_users.all(): - if status.reply_parent and mention_user == status.reply_parent.user: - continue - if mention_user.local: - create_notification( - mention_user, - 'MENTION', - related_user=user, - related_status=status - ) - - # don't apply formatting to generated notes - if not isinstance(status, models.GeneratedNote): - status.content = to_markdown(content) - # do apply formatting to quotes - if hasattr(status, 'quote'): - status.quote = to_markdown(status.quote) - - status.save() - - broadcast(user, status.to_create_activity(user), software='bookwyrm') - - # re-format the activity for non-bookwyrm servers - remote_activity = status.to_create_activity(user, pure=True) - broadcast(user, remote_activity, software='other') - - -def find_mentions(content): - ''' detect @mentions in raw status content ''' - for match in re.finditer(regex.strict_username, content): - username = match.group().strip().split('@')[1:] - if len(username) == 1: - # this looks like a local user (@user), fill in the domain - username.append(DOMAIN) - username = '@'.join(username) - - mention_user = handle_remote_webfinger(username) - if not mention_user: - # we can ignore users we don't know about - continue - yield (match.group(), mention_user) - - -def format_links(content): - ''' detect and format links ''' - return re.sub( - r'([^(href=")]|^|\()(https?:\/\/(%s([\w\.\-_\/+&\?=:;,])*))' % \ - regex.domain, - r'\g<1>\g<3>', - content) - -def to_markdown(content): - ''' catch links and convert to markdown ''' - content = format_links(content) - content = markdown(content) - # sanitize resulting html - sanitizer = InputHtmlParser() - sanitizer.feed(content) - return sanitizer.get_output() - - -def handle_favorite(user, status): - ''' a user likes a status ''' - try: - favorite = models.Favorite.objects.create( - status=status, - user=user - ) - except IntegrityError: - # you already fav'ed that - return - - fav_activity = favorite.to_activity() - broadcast( - user, fav_activity, privacy='direct', direct_recipients=[status.user]) - if status.user.local: - create_notification( - status.user, - 'FAVORITE', - related_user=user, - related_status=status - ) - - -def handle_unfavorite(user, status): - ''' a user likes a status ''' - try: - favorite = models.Favorite.objects.get( - status=status, - user=user - ) - except models.Favorite.DoesNotExist: - # can't find that status, idk - return - - fav_activity = favorite.to_undo_activity(user) - favorite.delete() - broadcast(user, fav_activity, direct_recipients=[status.user]) - - # check for notification - if status.user.local: - notification = models.Notification.objects.filter( - user=status.user, related_user=user, - related_status=status, notification_type='FAVORITE' - ).first() - if notification: - notification.delete() - - -def handle_boost(user, status): - ''' a user wishes to boost a status ''' - # is it boostable? - if not status.boostable: - return - - if models.Boost.objects.filter( - boosted_status=status, user=user).exists(): - # you already boosted that. - return - boost = models.Boost.objects.create( - boosted_status=status, - privacy=status.privacy, - user=user, - ) - - boost_activity = boost.to_activity() - broadcast(user, boost_activity) - - if status.user.local: - create_notification( - status.user, - 'BOOST', - related_user=user, - related_status=status - ) - - -def handle_unboost(user, status): - ''' a user regrets boosting a status ''' - boost = models.Boost.objects.filter( - boosted_status=status, user=user - ).first() - activity = boost.to_undo_activity(user) - - boost.delete() - broadcast(user, activity) - - # delete related notification - if status.user.local: - notification = models.Notification.objects.filter( - user=status.user, related_user=user, - related_status=status, notification_type='BOOST' - ).first() - if notification: - notification.delete() diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index fab335b1a..cec44f4ae 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -9,6 +9,9 @@ .card { overflow: visible; } +.card-header-title { + overflow: hidden; +} /* --- TOGGLES --- */ input.toggle-control { diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index c54255fe2..4e65ecf84 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -223,7 +223,7 @@ -
+
{% for review in reviews %}
{% include 'snippets/status.html' with status=review hide_book=True depth=1 %} @@ -231,25 +231,28 @@ {% endfor %}
- {% for rating in ratings %} -
-
-
{% include 'snippets/avatar.html' with user=rating.user %}
-
-
- {% include 'snippets/username.html' with user=rating.user %} -
-
-
rated it
- {% include 'snippets/stars.html' with rating=rating.rating %} -
-
- {{ rating.published_date | naturaltime }} + {% for rating in ratings %} +
+
+
{% include 'snippets/avatar.html' with user=rating.user %}
+
+
+ {% include 'snippets/username.html' with user=rating.user %} +
+
+
rated it
+ {% include 'snippets/stars.html' with rating=rating.rating %} +
+
+ {% endfor %}
- {% endfor %} +
+ {% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
diff --git a/bookwyrm/templates/direct_messages.html b/bookwyrm/templates/direct_messages.html index 6a20b1114..666f52908 100644 --- a/bookwyrm/templates/direct_messages.html +++ b/bookwyrm/templates/direct_messages.html @@ -13,25 +13,7 @@
{% endfor %} - + {% include 'snippets/pagination.html' with page=activities path="direct-messages" %}
{% endblock %} diff --git a/bookwyrm/templates/discover.html b/bookwyrm/templates/discover.html index 79e31f564..2ff091f08 100644 --- a/bookwyrm/templates/discover.html +++ b/bookwyrm/templates/discover.html @@ -16,7 +16,7 @@
{% if site.allow_registration %}

Join {{ site.name }}

-
+ {% include 'snippets/register_form.html' %}
{% else %} diff --git a/bookwyrm/templates/edit_author.html b/bookwyrm/templates/edit_author.html index 26953af03..e007e69b2 100644 --- a/bookwyrm/templates/edit_author.html +++ b/bookwyrm/templates/edit_author.html @@ -18,7 +18,7 @@
{% endif %} -
+ {% csrf_token %} diff --git a/bookwyrm/templates/edit_book.html b/bookwyrm/templates/edit_book.html index 7bc6e112e..6e7e434e8 100644 --- a/bookwyrm/templates/edit_book.html +++ b/bookwyrm/templates/edit_book.html @@ -18,7 +18,7 @@
{% endif %} - + {% csrf_token %}
diff --git a/bookwyrm/templates/feed.html b/bookwyrm/templates/feed.html index 07f4c60ad..b77da819b 100644 --- a/bookwyrm/templates/feed.html +++ b/bookwyrm/templates/feed.html @@ -94,25 +94,7 @@
{% endfor %} - + {% include 'snippets/pagination.html' with page=activities path='/'|add:tab anchor="#feed" %}
{% endblock %} diff --git a/bookwyrm/templates/import.html b/bookwyrm/templates/import.html index bfa8d3ec8..23cd34454 100644 --- a/bookwyrm/templates/import.html +++ b/bookwyrm/templates/import.html @@ -3,7 +3,7 @@ {% block content %}

Import Books from GoodReads

- + {% csrf_token %}
{{ import_form.as_p }} @@ -30,7 +30,7 @@ {% endif %}
diff --git a/bookwyrm/templates/import_status.html b/bookwyrm/templates/import_status.html index ba462eeef..66b3fb676 100644 --- a/bookwyrm/templates/import_status.html +++ b/bookwyrm/templates/import_status.html @@ -30,9 +30,8 @@

Failed to load

{% if not job.retry %} - + {% csrf_token %} -
    {% for item in failed_items %} diff --git a/bookwyrm/templates/invite.html b/bookwyrm/templates/invite.html index 458ce3df7..3345424ce 100644 --- a/bookwyrm/templates/invite.html +++ b/bookwyrm/templates/invite.html @@ -7,7 +7,7 @@ {% if valid %}

    Create an Account

    - + {% include 'snippets/register_form.html' %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index 7a09f60dd..19ecc878e 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -82,7 +82,7 @@
  • - + Settings
  • @@ -122,10 +122,10 @@
    {% else %}
    {% endif %} - {% if type == 'quote' %} + {% if type == 'quotation' %} {% else %} {% endif %}
- {% if type == 'quote' %} + {% if type == 'quotation' %}
diff --git a/bookwyrm/templates/snippets/pagination.html b/bookwyrm/templates/snippets/pagination.html new file mode 100644 index 000000000..1855dfed5 --- /dev/null +++ b/bookwyrm/templates/snippets/pagination.html @@ -0,0 +1,19 @@ + diff --git a/bookwyrm/templates/snippets/reply_form.html b/bookwyrm/templates/snippets/reply_form.html index 4236d1b68..e86db0a6f 100644 --- a/bookwyrm/templates/snippets/reply_form.html +++ b/bookwyrm/templates/snippets/reply_form.html @@ -1,5 +1,6 @@ {% load bookwyrm_tags %} - +{% with status.id|uuid as uuid %} +
{% csrf_token %} diff --git a/bookwyrm/templates/user.html b/bookwyrm/templates/user.html index 5bfa048d6..ef5552c3c 100644 --- a/bookwyrm/templates/user.html +++ b/bookwyrm/templates/user.html @@ -7,7 +7,7 @@
{% if is_self %} {% for activity in activities %} -
+
{% include 'snippets/status.html' with status=activity %}
{% endfor %} @@ -55,25 +55,7 @@
{% endif %} -
+ {% include 'snippets/pagination.html' with page=activities path=user.local_path anchor="#feed" %}
{% endblock %} diff --git a/bookwyrm/templatetags/bookwyrm_tags.py b/bookwyrm/templatetags/bookwyrm_tags.py index d774a0793..c58f5c476 100644 --- a/bookwyrm/templatetags/bookwyrm_tags.py +++ b/bookwyrm/templatetags/bookwyrm_tags.py @@ -7,7 +7,7 @@ from django import template from django.utils import timezone from bookwyrm import models -from bookwyrm.outgoing import to_markdown +from bookwyrm.views.status import to_markdown register = template.Library() diff --git a/bookwyrm/tests/test_goodreads_import.py b/bookwyrm/tests/test_goodreads_import.py index 2518ab7b4..9519aab1b 100644 --- a/bookwyrm/tests/test_goodreads_import.py +++ b/bookwyrm/tests/test_goodreads_import.py @@ -1,5 +1,6 @@ ''' testing import ''' from collections import namedtuple +import csv import pathlib from unittest.mock import patch @@ -30,6 +31,12 @@ class GoodreadsImport(TestCase): search_url='https://%s/search?q=' % DOMAIN, priority=1, ) + 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=work + ) def test_create_job(self): @@ -97,8 +104,140 @@ class GoodreadsImport(TestCase): 'bookwyrm.models.import_job.ImportItem.get_book_from_isbn' ) as resolve: resolve.return_value = book - with patch('bookwyrm.outgoing.handle_imported_book'): + with patch('bookwyrm.goodreads_import.handle_imported_book'): goodreads_import.import_data(import_job.id) import_item = models.ImportItem.objects.get(job=import_job, index=0) self.assertEqual(import_item.book.id, book.id) + + + def test_handle_imported_book(self): + ''' goodreads import added a book, this adds related connections ''' + shelf = self.user.shelf_set.filter(identifier='read').first() + self.assertIsNone(shelf.books.first()) + + import_job = models.ImportJob.objects.create(user=self.user) + datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') + csv_file = open(datafile, 'r') + for index, entry in enumerate(list(csv.DictReader(csv_file))): + import_item = models.ImportItem.objects.create( + job_id=import_job.id, index=index, data=entry, book=self.book) + break + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + goodreads_import.handle_imported_book( + self.user, import_item, False, 'public') + + shelf.refresh_from_db() + self.assertEqual(shelf.books.first(), self.book) + + readthrough = models.ReadThrough.objects.get(user=self.user) + self.assertEqual(readthrough.book, self.book) + # I can't remember how to create dates and I don't want to look it up. + self.assertEqual(readthrough.start_date.year, 2020) + self.assertEqual(readthrough.start_date.month, 10) + self.assertEqual(readthrough.start_date.day, 21) + self.assertEqual(readthrough.finish_date.year, 2020) + self.assertEqual(readthrough.finish_date.month, 10) + self.assertEqual(readthrough.finish_date.day, 25) + + + def test_handle_imported_book_already_shelved(self): + ''' goodreads import added a book, this adds related connections ''' + shelf = self.user.shelf_set.filter(identifier='to-read').first() + models.ShelfBook.objects.create( + shelf=shelf, added_by=self.user, book=self.book) + + import_job = models.ImportJob.objects.create(user=self.user) + datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') + csv_file = open(datafile, 'r') + for index, entry in enumerate(list(csv.DictReader(csv_file))): + import_item = models.ImportItem.objects.create( + job_id=import_job.id, index=index, data=entry, book=self.book) + break + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + goodreads_import.handle_imported_book( + self.user, import_item, False, 'public') + + shelf.refresh_from_db() + self.assertEqual(shelf.books.first(), self.book) + self.assertIsNone( + self.user.shelf_set.get(identifier='read').books.first()) + readthrough = models.ReadThrough.objects.get(user=self.user) + self.assertEqual(readthrough.book, self.book) + self.assertEqual(readthrough.start_date.year, 2020) + self.assertEqual(readthrough.start_date.month, 10) + self.assertEqual(readthrough.start_date.day, 21) + self.assertEqual(readthrough.finish_date.year, 2020) + self.assertEqual(readthrough.finish_date.month, 10) + self.assertEqual(readthrough.finish_date.day, 25) + + + def test_handle_import_twice(self): + ''' re-importing books ''' + shelf = self.user.shelf_set.filter(identifier='read').first() + import_job = models.ImportJob.objects.create(user=self.user) + datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') + csv_file = open(datafile, 'r') + for index, entry in enumerate(list(csv.DictReader(csv_file))): + import_item = models.ImportItem.objects.create( + job_id=import_job.id, index=index, data=entry, book=self.book) + break + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + goodreads_import.handle_imported_book( + self.user, import_item, False, 'public') + goodreads_import.handle_imported_book( + self.user, import_item, False, 'public') + + shelf.refresh_from_db() + self.assertEqual(shelf.books.first(), self.book) + + readthrough = models.ReadThrough.objects.get(user=self.user) + self.assertEqual(readthrough.book, self.book) + # I can't remember how to create dates and I don't want to look it up. + self.assertEqual(readthrough.start_date.year, 2020) + self.assertEqual(readthrough.start_date.month, 10) + self.assertEqual(readthrough.start_date.day, 21) + self.assertEqual(readthrough.finish_date.year, 2020) + self.assertEqual(readthrough.finish_date.month, 10) + self.assertEqual(readthrough.finish_date.day, 25) + + + def test_handle_imported_book_review(self): + ''' goodreads review import ''' + import_job = models.ImportJob.objects.create(user=self.user) + datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') + csv_file = open(datafile, 'r') + entry = list(csv.DictReader(csv_file))[2] + import_item = models.ImportItem.objects.create( + job_id=import_job.id, index=0, data=entry, book=self.book) + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + goodreads_import.handle_imported_book( + self.user, import_item, True, 'unlisted') + review = models.Review.objects.get(book=self.book, user=self.user) + self.assertEqual(review.content, 'mixed feelings') + self.assertEqual(review.rating, 2) + self.assertEqual(review.published_date.year, 2019) + self.assertEqual(review.published_date.month, 7) + self.assertEqual(review.published_date.day, 8) + self.assertEqual(review.privacy, 'unlisted') + + + def test_handle_imported_book_reviews_disabled(self): + ''' goodreads review import ''' + import_job = models.ImportJob.objects.create(user=self.user) + datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') + csv_file = open(datafile, 'r') + entry = list(csv.DictReader(csv_file))[2] + import_item = models.ImportItem.objects.create( + job_id=import_job.id, index=0, data=entry, book=self.book) + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + goodreads_import.handle_imported_book( + self.user, import_item, False, 'unlisted') + self.assertFalse(models.Review.objects.filter( + book=self.book, user=self.user + ).exists()) diff --git a/bookwyrm/tests/test_outgoing.py b/bookwyrm/tests/test_outgoing.py deleted file mode 100644 index 67599673a..000000000 --- a/bookwyrm/tests/test_outgoing.py +++ /dev/null @@ -1,705 +0,0 @@ -''' sending out activities ''' -import csv -import json -import pathlib -from unittest.mock import patch - -from django.http import JsonResponse -from django.test import TestCase -from django.test.client import RequestFactory -import responses - -from bookwyrm import forms, models, outgoing -from bookwyrm.settings import DOMAIN - - -# pylint: disable=too-many-public-methods -class Outgoing(TestCase): - ''' sends out activities ''' - def setUp(self): - ''' we'll need some data ''' - self.factory = RequestFactory() - with patch('bookwyrm.models.user.set_remote_server'): - self.remote_user = models.User.objects.create_user( - 'rat', 'rat@email.com', 'ratword', - local=False, - remote_id='https://example.com/users/rat', - inbox='https://example.com/users/rat/inbox', - outbox='https://example.com/users/rat/outbox', - ) - 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', - ) - - datafile = pathlib.Path(__file__).parent.joinpath( - 'data/ap_user.json' - ) - self.userdata = json.loads(datafile.read_bytes()) - del self.userdata['icon'] - - 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=work - ) - self.shelf = models.Shelf.objects.create( - name='Test Shelf', - identifier='test-shelf', - user=self.local_user - ) - - - def test_outbox(self): - ''' returns user's statuses ''' - request = self.factory.get('') - result = outgoing.outbox(request, 'mouse') - self.assertIsInstance(result, JsonResponse) - - def test_outbox_bad_method(self): - ''' can't POST to outbox ''' - request = self.factory.post('') - result = outgoing.outbox(request, 'mouse') - self.assertEqual(result.status_code, 405) - - def test_outbox_unknown_user(self): - ''' should 404 for unknown and remote users ''' - request = self.factory.post('') - result = outgoing.outbox(request, 'beepboop') - self.assertEqual(result.status_code, 405) - result = outgoing.outbox(request, 'rat') - self.assertEqual(result.status_code, 405) - - def test_outbox_privacy(self): - ''' don't show dms et cetera in outbox ''' - models.Status.objects.create( - content='PRIVATE!!', user=self.local_user, privacy='direct') - models.Status.objects.create( - content='bffs ONLY', user=self.local_user, privacy='followers') - models.Status.objects.create( - content='unlisted status', user=self.local_user, privacy='unlisted') - models.Status.objects.create( - content='look at this', user=self.local_user, privacy='public') - - request = self.factory.get('') - result = outgoing.outbox(request, 'mouse') - self.assertIsInstance(result, JsonResponse) - data = json.loads(result.content) - self.assertEqual(data['type'], 'OrderedCollection') - self.assertEqual(data['totalItems'], 2) - - def test_outbox_filter(self): - ''' if we only care about reviews, only get reviews ''' - models.Review.objects.create( - content='look at this', name='hi', rating=1, - book=self.book, user=self.local_user) - models.Status.objects.create( - content='look at this', user=self.local_user) - - request = self.factory.get('', {'type': 'bleh'}) - result = outgoing.outbox(request, 'mouse') - self.assertIsInstance(result, JsonResponse) - data = json.loads(result.content) - self.assertEqual(data['type'], 'OrderedCollection') - self.assertEqual(data['totalItems'], 2) - - request = self.factory.get('', {'type': 'Review'}) - result = outgoing.outbox(request, 'mouse') - self.assertIsInstance(result, JsonResponse) - data = json.loads(result.content) - self.assertEqual(data['type'], 'OrderedCollection') - self.assertEqual(data['totalItems'], 1) - - - def test_handle_follow(self): - ''' send a follow request ''' - self.assertEqual(models.UserFollowRequest.objects.count(), 0) - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_follow(self.local_user, self.remote_user) - - rel = models.UserFollowRequest.objects.get() - - self.assertEqual(rel.user_subject, self.local_user) - self.assertEqual(rel.user_object, self.remote_user) - self.assertEqual(rel.status, 'follow_request') - - - def test_handle_unfollow(self): - ''' send an unfollow ''' - self.remote_user.followers.add(self.local_user) - self.assertEqual(self.remote_user.followers.count(), 1) - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_unfollow(self.local_user, self.remote_user) - - self.assertEqual(self.remote_user.followers.count(), 0) - - - def test_handle_accept(self): - ''' accept a follow request ''' - rel = models.UserFollowRequest.objects.create( - user_subject=self.local_user, - user_object=self.remote_user - ) - rel_id = rel.id - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_accept(rel) - # request should be deleted - self.assertEqual( - models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 - ) - # follow relationship should exist - self.assertEqual(self.remote_user.followers.first(), self.local_user) - - - def test_handle_reject(self): - ''' reject a follow request ''' - rel = models.UserFollowRequest.objects.create( - user_subject=self.local_user, - user_object=self.remote_user - ) - rel_id = rel.id - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_reject(rel) - # request should be deleted - self.assertEqual( - models.UserFollowRequest.objects.filter(id=rel_id).count(), 0 - ) - # follow relationship should not exist - self.assertEqual( - models.UserFollows.objects.filter(id=rel_id).count(), 0 - ) - - def test_existing_user(self): - ''' simple database lookup by username ''' - result = outgoing.handle_remote_webfinger('@mouse@local.com') - self.assertEqual(result, self.local_user) - - result = outgoing.handle_remote_webfinger('mouse@local.com') - self.assertEqual(result, self.local_user) - - - @responses.activate - def test_load_user(self): - ''' find a remote user using webfinger ''' - username = 'mouse@example.com' - wellknown = { - "subject": "acct:mouse@example.com", - "links": [{ - "rel": "self", - "type": "application/activity+json", - "href": "https://example.com/user/mouse" - }] - } - responses.add( - responses.GET, - 'https://example.com/.well-known/webfinger?resource=acct:%s' \ - % username, - json=wellknown, - status=200) - responses.add( - responses.GET, - 'https://example.com/user/mouse', - json=self.userdata, - status=200) - with patch('bookwyrm.models.user.set_remote_server.delay'): - result = outgoing.handle_remote_webfinger('@mouse@example.com') - self.assertIsInstance(result, models.User) - self.assertEqual(result.username, 'mouse@example.com') - - - def test_handle_shelve(self): - ''' shelve a book ''' - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_shelve(self.local_user, self.book, self.shelf) - # 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') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_shelve(self.local_user, self.book, shelf) - # 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') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_shelve(self.local_user, self.book, shelf) - # 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') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_shelve(self.local_user, self.book, shelf) - # 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) - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_unshelve(self.local_user, self.book, self.shelf) - self.assertEqual(self.shelf.books.count(), 0) - - - def test_handle_reading_status_to_read(self): - ''' posts shelve activities ''' - shelf = self.local_user.shelf_set.get(identifier='to-read') - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_reading_status( - self.local_user, shelf, self.book, 'public') - status = models.GeneratedNote.objects.get() - self.assertEqual(status.user, self.local_user) - self.assertEqual(status.mention_books.first(), self.book) - self.assertEqual(status.content, 'wants to read') - - def test_handle_reading_status_reading(self): - ''' posts shelve activities ''' - shelf = self.local_user.shelf_set.get(identifier='reading') - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_reading_status( - self.local_user, shelf, self.book, 'public') - status = models.GeneratedNote.objects.get() - self.assertEqual(status.user, self.local_user) - self.assertEqual(status.mention_books.first(), self.book) - self.assertEqual(status.content, 'started reading') - - def test_handle_reading_status_read(self): - ''' posts shelve activities ''' - shelf = self.local_user.shelf_set.get(identifier='read') - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_reading_status( - self.local_user, shelf, self.book, 'public') - status = models.GeneratedNote.objects.get() - self.assertEqual(status.user, self.local_user) - self.assertEqual(status.mention_books.first(), self.book) - self.assertEqual(status.content, 'finished reading') - - def test_handle_reading_status_other(self): - ''' posts shelve activities ''' - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_reading_status( - self.local_user, self.shelf, self.book, 'public') - self.assertFalse(models.GeneratedNote.objects.exists()) - - - def test_handle_imported_book(self): - ''' goodreads import added a book, this adds related connections ''' - shelf = self.local_user.shelf_set.filter(identifier='read').first() - self.assertIsNone(shelf.books.first()) - - import_job = models.ImportJob.objects.create(user=self.local_user) - datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') - csv_file = open(datafile, 'r') - for index, entry in enumerate(list(csv.DictReader(csv_file))): - import_item = models.ImportItem.objects.create( - job_id=import_job.id, index=index, data=entry, book=self.book) - break - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_imported_book( - self.local_user, import_item, False, 'public') - - shelf.refresh_from_db() - self.assertEqual(shelf.books.first(), self.book) - - readthrough = models.ReadThrough.objects.get(user=self.local_user) - self.assertEqual(readthrough.book, self.book) - # I can't remember how to create dates and I don't want to look it up. - self.assertEqual(readthrough.start_date.year, 2020) - self.assertEqual(readthrough.start_date.month, 10) - self.assertEqual(readthrough.start_date.day, 21) - self.assertEqual(readthrough.finish_date.year, 2020) - self.assertEqual(readthrough.finish_date.month, 10) - self.assertEqual(readthrough.finish_date.day, 25) - - - def test_handle_imported_book_already_shelved(self): - ''' goodreads import added a book, this adds related connections ''' - shelf = self.local_user.shelf_set.filter(identifier='to-read').first() - models.ShelfBook.objects.create( - shelf=shelf, added_by=self.local_user, book=self.book) - - import_job = models.ImportJob.objects.create(user=self.local_user) - datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') - csv_file = open(datafile, 'r') - for index, entry in enumerate(list(csv.DictReader(csv_file))): - import_item = models.ImportItem.objects.create( - job_id=import_job.id, index=index, data=entry, book=self.book) - break - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_imported_book( - self.local_user, import_item, False, 'public') - - shelf.refresh_from_db() - self.assertEqual(shelf.books.first(), self.book) - self.assertIsNone( - self.local_user.shelf_set.get(identifier='read').books.first()) - readthrough = models.ReadThrough.objects.get(user=self.local_user) - self.assertEqual(readthrough.book, self.book) - self.assertEqual(readthrough.start_date.year, 2020) - self.assertEqual(readthrough.start_date.month, 10) - self.assertEqual(readthrough.start_date.day, 21) - self.assertEqual(readthrough.finish_date.year, 2020) - self.assertEqual(readthrough.finish_date.month, 10) - self.assertEqual(readthrough.finish_date.day, 25) - - - def test_handle_import_twice(self): - ''' re-importing books ''' - shelf = self.local_user.shelf_set.filter(identifier='read').first() - import_job = models.ImportJob.objects.create(user=self.local_user) - datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') - csv_file = open(datafile, 'r') - for index, entry in enumerate(list(csv.DictReader(csv_file))): - import_item = models.ImportItem.objects.create( - job_id=import_job.id, index=index, data=entry, book=self.book) - break - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_imported_book( - self.local_user, import_item, False, 'public') - outgoing.handle_imported_book( - self.local_user, import_item, False, 'public') - - shelf.refresh_from_db() - self.assertEqual(shelf.books.first(), self.book) - - readthrough = models.ReadThrough.objects.get(user=self.local_user) - self.assertEqual(readthrough.book, self.book) - # I can't remember how to create dates and I don't want to look it up. - self.assertEqual(readthrough.start_date.year, 2020) - self.assertEqual(readthrough.start_date.month, 10) - self.assertEqual(readthrough.start_date.day, 21) - self.assertEqual(readthrough.finish_date.year, 2020) - self.assertEqual(readthrough.finish_date.month, 10) - self.assertEqual(readthrough.finish_date.day, 25) - - - def test_handle_imported_book_review(self): - ''' goodreads review import ''' - import_job = models.ImportJob.objects.create(user=self.local_user) - datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') - csv_file = open(datafile, 'r') - entry = list(csv.DictReader(csv_file))[2] - import_item = models.ImportItem.objects.create( - job_id=import_job.id, index=0, data=entry, book=self.book) - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_imported_book( - self.local_user, import_item, True, 'unlisted') - review = models.Review.objects.get(book=self.book, user=self.local_user) - self.assertEqual(review.content, 'mixed feelings') - self.assertEqual(review.rating, 2) - self.assertEqual(review.published_date.year, 2019) - self.assertEqual(review.published_date.month, 7) - self.assertEqual(review.published_date.day, 8) - self.assertEqual(review.privacy, 'unlisted') - - - def test_handle_imported_book_reviews_disabled(self): - ''' goodreads review import ''' - import_job = models.ImportJob.objects.create(user=self.local_user) - datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv') - csv_file = open(datafile, 'r') - entry = list(csv.DictReader(csv_file))[2] - import_item = models.ImportItem.objects.create( - job_id=import_job.id, index=0, data=entry, book=self.book) - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_imported_book( - self.local_user, import_item, False, 'unlisted') - self.assertFalse(models.Review.objects.filter( - book=self.book, user=self.local_user - ).exists()) - - - def test_handle_delete_status(self): - ''' marks a status as deleted ''' - status = models.Status.objects.create( - user=self.local_user, content='hi') - self.assertFalse(status.deleted) - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_delete_status(self.local_user, status) - status.refresh_from_db() - self.assertTrue(status.deleted) - - - def test_handle_status(self): - ''' create a status ''' - form = forms.CommentForm({ - 'content': 'hi', - 'user': self.local_user.id, - 'book': self.book.id, - 'privacy': 'public', - }) - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_status(self.local_user, form) - status = models.Comment.objects.get() - self.assertEqual(status.content, '

hi

') - self.assertEqual(status.user, self.local_user) - self.assertEqual(status.book, self.book) - - def test_handle_status_reply(self): - ''' create a status in reply to an existing status ''' - user = models.User.objects.create_user( - 'rat', 'rat@rat.com', 'password', local=True) - parent = models.Status.objects.create( - content='parent status', user=self.local_user) - form = forms.ReplyForm({ - 'content': 'hi', - 'user': user.id, - 'reply_parent': parent.id, - 'privacy': 'public', - }) - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_status(user, form) - status = models.Status.objects.get(user=user) - self.assertEqual(status.content, '

hi

') - self.assertEqual(status.user, user) - self.assertEqual( - models.Notification.objects.get().user, self.local_user) - - def test_handle_status_mentions(self): - ''' @mention a user in a post ''' - user = models.User.objects.create_user( - 'rat@%s' % DOMAIN, 'rat@rat.com', 'password', - local=True, localname='rat') - form = forms.CommentForm({ - 'content': 'hi @rat', - 'user': self.local_user.id, - 'book': self.book.id, - 'privacy': 'public', - }) - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_status(self.local_user, form) - status = models.Status.objects.get() - self.assertEqual(list(status.mention_users.all()), [user]) - self.assertEqual(models.Notification.objects.get().user, user) - self.assertEqual( - status.content, - '

hi @rat

' % user.remote_id) - - def test_handle_status_reply_with_mentions(self): - ''' reply to a post with an @mention'ed user ''' - user = models.User.objects.create_user( - 'rat', 'rat@rat.com', 'password', - local=True, localname='rat') - form = forms.CommentForm({ - 'content': 'hi @rat@example.com', - 'user': self.local_user.id, - 'book': self.book.id, - 'privacy': 'public', - }) - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_status(self.local_user, form) - status = models.Status.objects.get() - - form = forms.ReplyForm({ - 'content': 'right', - 'user': user, - 'privacy': 'public', - 'reply_parent': status.id - }) - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_status(user, form) - - reply = models.Status.replies(status).first() - self.assertEqual(reply.content, '

right

') - self.assertEqual(reply.user, user) - self.assertTrue(self.remote_user in reply.mention_users.all()) - self.assertTrue(self.local_user in reply.mention_users.all()) - - def test_find_mentions(self): - ''' detect and look up @ mentions of users ''' - user = models.User.objects.create_user( - 'nutria@%s' % DOMAIN, 'nutria@nutria.com', 'password', - local=True, localname='nutria') - self.assertEqual(user.username, 'nutria@%s' % DOMAIN) - - self.assertEqual( - list(outgoing.find_mentions('@nutria'))[0], - ('@nutria', user) - ) - self.assertEqual( - list(outgoing.find_mentions('leading text @nutria'))[0], - ('@nutria', user) - ) - self.assertEqual( - list(outgoing.find_mentions('leading @nutria trailing text'))[0], - ('@nutria', user) - ) - self.assertEqual( - list(outgoing.find_mentions('@rat@example.com'))[0], - ('@rat@example.com', self.remote_user) - ) - - multiple = list(outgoing.find_mentions('@nutria and @rat@example.com')) - self.assertEqual(multiple[0], ('@nutria', user)) - self.assertEqual(multiple[1], ('@rat@example.com', self.remote_user)) - - with patch('bookwyrm.outgoing.handle_remote_webfinger') as rw: - rw.return_value = self.local_user - self.assertEqual( - list(outgoing.find_mentions('@beep@beep.com'))[0], - ('@beep@beep.com', self.local_user) - ) - with patch('bookwyrm.outgoing.handle_remote_webfinger') as rw: - rw.return_value = None - self.assertEqual(list(outgoing.find_mentions('@beep@beep.com')), []) - - self.assertEqual( - list(outgoing.find_mentions('@nutria@%s' % DOMAIN))[0], - ('@nutria@%s' % DOMAIN, user) - ) - - def test_format_links(self): - ''' find and format urls into a tags ''' - url = 'http://www.fish.com/' - self.assertEqual( - outgoing.format_links(url), - 'www.fish.com/' % url) - self.assertEqual( - outgoing.format_links('(%s)' % url), - '(www.fish.com/)' % url) - url = 'https://archive.org/details/dli.granth.72113/page/n25/mode/2up' - self.assertEqual( - outgoing.format_links(url), - '' \ - 'archive.org/details/dli.granth.72113/page/n25/mode/2up' \ - % url) - url = 'https://openlibrary.org/search' \ - '?q=arkady+strugatsky&mode=everything' - self.assertEqual( - outgoing.format_links(url), - 'openlibrary.org/search' \ - '?q=arkady+strugatsky&mode=everything' % url) - - - def test_to_markdown(self): - ''' this is mostly handled in other places, but nonetheless ''' - text = '_hi_ and http://fish.com is rad' - result = outgoing.to_markdown(text) - self.assertEqual( - result, - '

hi and fish.com ' \ - 'is rad

') - - - def test_handle_favorite(self): - ''' create and broadcast faving a status ''' - status = models.Status.objects.create( - user=self.local_user, content='hi') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_favorite(self.remote_user, status) - fav = models.Favorite.objects.get() - self.assertEqual(fav.status, status) - self.assertEqual(fav.user, self.remote_user) - - notification = models.Notification.objects.get() - self.assertEqual(notification.notification_type, 'FAVORITE') - self.assertEqual(notification.user, self.local_user) - self.assertEqual(notification.related_user, self.remote_user) - - - def test_handle_unfavorite(self): - ''' unfav a status ''' - status = models.Status.objects.create( - user=self.local_user, content='hi') - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_favorite(self.remote_user, status) - - self.assertEqual(models.Favorite.objects.count(), 1) - self.assertEqual(models.Notification.objects.count(), 1) - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_unfavorite(self.remote_user, status) - self.assertEqual(models.Favorite.objects.count(), 0) - self.assertEqual(models.Notification.objects.count(), 0) - - - def test_handle_boost(self): - ''' boost a status ''' - status = models.Status.objects.create( - user=self.local_user, content='hi') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_boost(self.remote_user, status) - - boost = models.Boost.objects.get() - self.assertEqual(boost.boosted_status, status) - self.assertEqual(boost.user, self.remote_user) - self.assertEqual(boost.privacy, 'public') - - notification = models.Notification.objects.get() - self.assertEqual(notification.notification_type, 'BOOST') - self.assertEqual(notification.user, self.local_user) - self.assertEqual(notification.related_user, self.remote_user) - self.assertEqual(notification.related_status, status) - - def test_handle_boost_unlisted(self): - ''' boost a status ''' - status = models.Status.objects.create( - user=self.local_user, content='hi', privacy='unlisted') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_boost(self.remote_user, status) - - boost = models.Boost.objects.get() - self.assertEqual(boost.privacy, 'unlisted') - - def test_handle_boost_private(self): - ''' boost a status ''' - status = models.Status.objects.create( - user=self.local_user, content='hi', privacy='followers') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_boost(self.remote_user, status) - self.assertFalse(models.Boost.objects.exists()) - - def test_handle_boost_twice(self): - ''' boost a status ''' - status = models.Status.objects.create( - user=self.local_user, content='hi') - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_boost(self.remote_user, status) - outgoing.handle_boost(self.remote_user, status) - self.assertEqual(models.Boost.objects.count(), 1) - - - def test_handle_unboost(self): - ''' undo a boost ''' - status = models.Status.objects.create( - user=self.local_user, content='hi') - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_boost(self.remote_user, status) - - self.assertEqual(models.Boost.objects.count(), 1) - self.assertEqual(models.Notification.objects.count(), 1) - with patch('bookwyrm.broadcast.broadcast_task.delay'): - outgoing.handle_unboost(self.remote_user, status) - self.assertEqual(models.Boost.objects.count(), 0) - self.assertEqual(models.Notification.objects.count(), 0) diff --git a/bookwyrm/tests/test_templatetags.py b/bookwyrm/tests/test_templatetags.py index 004dd8930..2cc61eab3 100644 --- a/bookwyrm/tests/test_templatetags.py +++ b/bookwyrm/tests/test_templatetags.py @@ -2,7 +2,6 @@ import re from unittest.mock import patch -from dateutil.parser import parse from dateutil.relativedelta import relativedelta from django.test import TestCase from django.utils import timezone @@ -213,3 +212,53 @@ class TemplateTags(TestCase): r'[A-Z][a-z]{2} \d?\d \d{4}', bookwyrm_tags.time_since(years_ago) )) + + + def test_get_markdown(self): + ''' mardown format data ''' + result = bookwyrm_tags.get_markdown('_hi_') + self.assertEqual(result, '

hi

') + + result = bookwyrm_tags.get_markdown('_hi_') + self.assertEqual(result, '

hi

') + + + def test_get_mentions(self): + ''' list of people mentioned ''' + status = models.Status.objects.create( + content='hi', user=self.remote_user) + result = bookwyrm_tags.get_mentions(status, self.user) + self.assertEqual(result, '@rat@example.com') + + + def test_get_status_preview_name(self): + ''' status context string ''' + status = models.Status.objects.create(content='hi', user=self.user) + result = bookwyrm_tags.get_status_preview_name(status) + self.assertEqual(result, 'status') + + status = models.Review.objects.create( + content='hi', user=self.user, book=self.book) + result = bookwyrm_tags.get_status_preview_name(status) + self.assertEqual(result, 'review of Test Book') + + status = models.Comment.objects.create( + content='hi', user=self.user, book=self.book) + result = bookwyrm_tags.get_status_preview_name(status) + self.assertEqual(result, 'comment on Test Book') + + status = models.Quotation.objects.create( + content='hi', user=self.user, book=self.book) + result = bookwyrm_tags.get_status_preview_name(status) + self.assertEqual(result, 'quotation from Test Book') + + + def test_related_status(self): + ''' gets the subclass model for a notification status ''' + status = models.Status.objects.create(content='hi', user=self.user) + notification = models.Notification.objects.create( + user=self.user, notification_type='MENTION', + related_status=status) + + result = bookwyrm_tags.related_status(notification) + self.assertIsInstance(result, models.Status) diff --git a/bookwyrm/tests/test_view_actions.py b/bookwyrm/tests/test_view_actions.py deleted file mode 100644 index 30549bf21..000000000 --- a/bookwyrm/tests/test_view_actions.py +++ /dev/null @@ -1,534 +0,0 @@ -''' test for app action functionality ''' -from unittest.mock import patch - -import dateutil -from django.core.exceptions import PermissionDenied -from django.contrib.auth.models import Group, Permission -from django.contrib.contenttypes.models import ContentType -from django.http.response import Http404 -from django.test import TestCase -from django.test.client import RequestFactory -from django.utils import timezone - -from bookwyrm import forms, models, view_actions as actions -from bookwyrm.settings import DOMAIN - - -#pylint: disable=too-many-public-methods -class ViewActions(TestCase): - ''' a lot here: all handlers for receiving activitypub requests ''' - def setUp(self): - ''' we need basic things, like users ''' - self.local_user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.com', 'mouseword', - local=True, localname='mouse') - self.local_user.remote_id = 'https://example.com/user/mouse' - self.local_user.save() - 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 - ) - with patch('bookwyrm.models.user.set_remote_server.delay'): - self.remote_user = models.User.objects.create_user( - 'rat', 'rat@rat.com', 'ratword', - local=False, - remote_id='https://example.com/users/rat', - inbox='https://example.com/users/rat/inbox', - outbox='https://example.com/users/rat/outbox', - ) - self.status = models.Status.objects.create( - user=self.local_user, - content='Test status', - remote_id='https://example.com/status/1', - ) - self.work = models.Work.objects.create(title='Test Work') - self.book = models.Edition.objects.create( - title='Test Book', parent_work=self.work) - self.settings = models.SiteSettings.objects.create(id=1) - self.factory = RequestFactory() - - - def test_register(self): - ''' create a user ''' - self.assertEqual(models.User.objects.count(), 2) - request = self.factory.post( - 'register/', - { - 'localname': 'nutria-user.user_nutria', - 'password': 'mouseword', - 'email': 'aa@bb.cccc' - }) - with patch('bookwyrm.view_actions.login'): - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 3) - self.assertEqual(response.status_code, 302) - nutria = models.User.objects.last() - self.assertEqual(nutria.username, 'nutria-user.user_nutria@%s' % DOMAIN) - self.assertEqual(nutria.localname, 'nutria-user.user_nutria') - self.assertEqual(nutria.local, True) - - def test_register_trailing_space(self): - ''' django handles this so weirdly ''' - request = self.factory.post( - 'register/', - { - 'localname': 'nutria ', - 'password': 'mouseword', - 'email': 'aa@bb.ccc' - }) - with patch('bookwyrm.view_actions.login'): - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 3) - self.assertEqual(response.status_code, 302) - nutria = models.User.objects.last() - self.assertEqual(nutria.username, 'nutria@%s' % DOMAIN) - self.assertEqual(nutria.localname, 'nutria') - self.assertEqual(nutria.local, True) - - def test_register_invalid_email(self): - ''' gotta have an email ''' - self.assertEqual(models.User.objects.count(), 2) - request = self.factory.post( - 'register/', - { - 'localname': 'nutria', - 'password': 'mouseword', - 'email': 'aa' - }) - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 2) - self.assertEqual(response.template_name, 'login.html') - - def test_register_invalid_username(self): - ''' gotta have an email ''' - self.assertEqual(models.User.objects.count(), 2) - request = self.factory.post( - 'register/', - { - 'localname': 'nut@ria', - 'password': 'mouseword', - 'email': 'aa@bb.ccc' - }) - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 2) - self.assertEqual(response.template_name, 'login.html') - - request = self.factory.post( - 'register/', - { - 'localname': 'nutr ia', - 'password': 'mouseword', - 'email': 'aa@bb.ccc' - }) - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 2) - self.assertEqual(response.template_name, 'login.html') - - request = self.factory.post( - 'register/', - { - 'localname': 'nut@ria', - 'password': 'mouseword', - 'email': 'aa@bb.ccc' - }) - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 2) - self.assertEqual(response.template_name, 'login.html') - - - def test_register_closed_instance(self): - ''' you can't just register ''' - self.settings.allow_registration = False - self.settings.save() - request = self.factory.post( - 'register/', - { - 'localname': 'nutria ', - 'password': 'mouseword', - 'email': 'aa@bb.ccc' - }) - with self.assertRaises(PermissionDenied): - actions.register(request) - - def test_register_invite(self): - ''' you can't just register ''' - self.settings.allow_registration = False - self.settings.save() - models.SiteInvite.objects.create( - code='testcode', user=self.local_user, use_limit=1) - self.assertEqual(models.SiteInvite.objects.get().times_used, 0) - - request = self.factory.post( - 'register/', - { - 'localname': 'nutria', - 'password': 'mouseword', - 'email': 'aa@bb.ccc', - 'invite_code': 'testcode' - }) - with patch('bookwyrm.view_actions.login'): - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 3) - self.assertEqual(response.status_code, 302) - self.assertEqual(models.SiteInvite.objects.get().times_used, 1) - - # invite already used to max capacity - request = self.factory.post( - 'register/', - { - 'localname': 'nutria2', - 'password': 'mouseword', - 'email': 'aa@bb.ccc', - 'invite_code': 'testcode' - }) - with self.assertRaises(PermissionDenied): - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 3) - - # bad invite code - request = self.factory.post( - 'register/', - { - 'localname': 'nutria3', - 'password': 'mouseword', - 'email': 'aa@bb.ccc', - 'invite_code': 'dkfkdjgdfkjgkdfj' - }) - with self.assertRaises(Http404): - response = actions.register(request) - self.assertEqual(models.User.objects.count(), 3) - - - def test_password_reset_request(self): - ''' send 'em an email ''' - request = self.factory.post('', {'email': 'aa@bb.ccc'}) - resp = actions.password_reset_request(request) - self.assertEqual(resp.status_code, 302) - - request = self.factory.post( - '', {'email': 'mouse@mouse.com'}) - with patch('bookwyrm.emailing.send_email.delay'): - resp = actions.password_reset_request(request) - self.assertEqual(resp.template_name, 'password_reset_request.html') - - self.assertEqual( - models.PasswordReset.objects.get().user, self.local_user) - - def test_password_reset(self): - ''' reset from code ''' - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'reset-code': code.code, - 'password': 'hi', - 'confirm-password': 'hi' - }) - with patch('bookwyrm.view_actions.login'): - resp = actions.password_reset(request) - self.assertEqual(resp.status_code, 302) - self.assertFalse(models.PasswordReset.objects.exists()) - - def test_password_reset_wrong_code(self): - ''' reset from code ''' - models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'reset-code': 'jhgdkfjgdf', - 'password': 'hi', - 'confirm-password': 'hi' - }) - resp = actions.password_reset(request) - self.assertEqual(resp.template_name, 'password_reset.html') - self.assertTrue(models.PasswordReset.objects.exists()) - - def test_password_reset_mismatch(self): - ''' reset from code ''' - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.post('', { - 'reset-code': code.code, - 'password': 'hi', - 'confirm-password': 'hihi' - }) - resp = actions.password_reset(request) - self.assertEqual(resp.template_name, 'password_reset.html') - self.assertTrue(models.PasswordReset.objects.exists()) - - - def test_password_change(self): - ''' change password ''' - password_hash = self.local_user.password - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hi' - }) - request.user = self.local_user - with patch('bookwyrm.view_actions.login'): - actions.password_change(request) - self.assertNotEqual(self.local_user.password, password_hash) - - def test_password_change_mismatch(self): - ''' change password ''' - password_hash = self.local_user.password - request = self.factory.post('', { - 'password': 'hi', - 'confirm-password': 'hihi' - }) - request.user = self.local_user - actions.password_change(request) - self.assertEqual(self.local_user.password, password_hash) - - - def test_edit_user(self): - ''' use a form to update a user ''' - form = forms.EditUserForm(instance=self.local_user) - form.data['name'] = 'New Name' - request = self.factory.post('', form.data) - request.user = self.local_user - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - actions.edit_profile(request) - self.assertEqual(self.local_user.name, 'New Name') - - - def test_edit_book(self): - ''' lets a user edit a book ''' - 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'): - actions.edit_book(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'): - actions.switch_edition(request) - - self.assertEqual(models.ShelfBook.objects.get().book, edition2) - self.assertEqual(models.ReadThrough.objects.get().book, edition2) - - - def test_edit_author(self): - ''' edit an author ''' - author = models.Author.objects.create(name='Test Author') - self.local_user.groups.add(self.group) - form = forms.AuthorForm(instance=author) - form.data['name'] = 'New Name' - 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'): - actions.edit_author(request, author.id) - author.refresh_from_db() - self.assertEqual(author.name, 'New Name') - self.assertEqual(author.last_edited_by, self.local_user) - - def test_edit_author_non_editor(self): - ''' edit an author with invalid post data''' - author = models.Author.objects.create(name='Test Author') - form = forms.AuthorForm(instance=author) - form.data['name'] = 'New Name' - form.data['last_edited_by'] = self.local_user.id - request = self.factory.post('', form.data) - request.user = self.local_user - with self.assertRaises(PermissionDenied): - actions.edit_author(request, author.id) - author.refresh_from_db() - self.assertEqual(author.name, 'Test Author') - - def test_edit_author_invalid_form(self): - ''' edit an author with invalid post data''' - author = models.Author.objects.create(name='Test Author') - self.local_user.groups.add(self.group) - form = forms.AuthorForm(instance=author) - form.data['name'] = '' - form.data['last_edited_by'] = self.local_user.id - request = self.factory.post('', form.data) - request.user = self.local_user - resp = actions.edit_author(request, author.id) - author.refresh_from_db() - self.assertEqual(author.name, 'Test Author') - self.assertEqual(resp.template_name, 'edit_author.html') - - - def test_edit_shelf_privacy(self): - ''' set name or privacy on shelf ''' - 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 - actions.edit_shelf(request, shelf.id) - shelf.refresh_from_db() - - self.assertEqual(shelf.privacy, 'unlisted') - - - def test_edit_shelf_name(self): - ''' change the name of an editable shelf ''' - 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 - actions.edit_shelf(request, 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 ''' - 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 - actions.edit_shelf(request, shelf.id) - - self.assertEqual(shelf.name, 'To Read') - - - def test_edit_readthrough(self): - ''' adding dates to an ongoing readthrough ''' - start = timezone.make_aware(dateutil.parser.parse('2021-01-03')) - readthrough = models.ReadThrough.objects.create( - book=self.book, user=self.local_user, start_date=start) - request = self.factory.post( - '', { - 'start_date': '2017-01-01', - 'finish_date': '2018-03-07', - 'book': '', - 'id': readthrough.id, - }) - request.user = self.local_user - - actions.edit_readthrough(request) - readthrough.refresh_from_db() - self.assertEqual(readthrough.start_date.year, 2017) - self.assertEqual(readthrough.start_date.month, 1) - self.assertEqual(readthrough.start_date.day, 1) - self.assertEqual(readthrough.finish_date.year, 2018) - self.assertEqual(readthrough.finish_date.month, 3) - self.assertEqual(readthrough.finish_date.day, 7) - self.assertEqual(readthrough.book, self.book) - - - def test_delete_readthrough(self): - ''' remove a readthrough ''' - readthrough = models.ReadThrough.objects.create( - book=self.book, user=self.local_user) - models.ReadThrough.objects.create( - book=self.book, user=self.local_user) - request = self.factory.post( - '', { - 'id': readthrough.id, - }) - request.user = self.local_user - - actions.delete_readthrough(request) - self.assertFalse( - models.ReadThrough.objects.filter(id=readthrough.id).exists()) - - - def test_create_readthrough(self): - ''' adding new read dates ''' - request = self.factory.post( - '', { - 'start_date': '2017-01-01', - 'finish_date': '2018-03-07', - 'book': self.book.id, - 'id': '', - }) - request.user = self.local_user - - actions.create_readthrough(request) - readthrough = models.ReadThrough.objects.get() - self.assertEqual(readthrough.start_date.year, 2017) - self.assertEqual(readthrough.start_date.month, 1) - self.assertEqual(readthrough.start_date.day, 1) - self.assertEqual(readthrough.finish_date.year, 2018) - self.assertEqual(readthrough.finish_date.month, 3) - self.assertEqual(readthrough.finish_date.day, 7) - self.assertEqual(readthrough.book, self.book) - self.assertEqual(readthrough.user, self.local_user) - - - def test_tag(self): - ''' add a tag to a book ''' - request = self.factory.post( - '', { - 'name': 'A Tag!?', - 'book': self.book.id, - }) - request.user = self.local_user - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - actions.tag(request) - - tag = models.Tag.objects.get() - user_tag = models.UserTag.objects.get() - self.assertEqual(tag.name, 'A Tag!?') - self.assertEqual(tag.identifier, 'A+Tag%21%3F') - self.assertEqual(user_tag.user, self.local_user) - self.assertEqual(user_tag.book, self.book) - - - def test_untag(self): - ''' remove a tag from a book ''' - tag = models.Tag.objects.create(name='A Tag!?') - models.UserTag.objects.create( - user=self.local_user, book=self.book, tag=tag) - request = self.factory.post( - '', { - 'user': self.local_user.id, - 'book': self.book.id, - 'name': tag.name, - }) - request.user = self.local_user - - with patch('bookwyrm.broadcast.broadcast_task.delay'): - actions.untag(request) - - self.assertTrue(models.Tag.objects.filter(name='A Tag!?').exists()) - self.assertFalse(models.UserTag.objects.exists()) diff --git a/bookwyrm/tests/test_views.py b/bookwyrm/tests/test_views.py deleted file mode 100644 index eee69d063..000000000 --- a/bookwyrm/tests/test_views.py +++ /dev/null @@ -1,597 +0,0 @@ -''' test for app action functionality ''' -import json -from unittest.mock import patch - -from django.contrib.auth.models import AnonymousUser -from django.http import JsonResponse -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 -from bookwyrm.connectors import abstract_connector -from bookwyrm.settings import DOMAIN, USER_AGENT - - -# pylint: disable=too-many-public-methods -class Views(TestCase): - ''' every response to a get request, html or json ''' - def setUp(self): - ''' we need basic test data and mocks ''' - self.factory = RequestFactory() - self.work = models.Work.objects.create(title='Test Work') - self.book = models.Edition.objects.create( - title='Test Book', parent_work=self.work) - models.Connector.objects.create( - identifier='self', - connector_file='self_connector', - local=True - ) - self.local_user = models.User.objects.create_user( - 'mouse@local.com', 'mouse@mouse.mouse', 'password', - local=True, localname='mouse') - with patch('bookwyrm.models.user.set_remote_server.delay'): - self.remote_user = models.User.objects.create_user( - 'rat', 'rat@rat.com', 'ratword', - local=False, - remote_id='https://example.com/users/rat', - inbox='https://example.com/users/rat/inbox', - outbox='https://example.com/users/rat/outbox', - ) - - - def test_get_edition(self): - ''' given an edition or a work, returns an edition ''' - self.assertEqual( - views.get_edition(self.book.id), self.book) - self.assertEqual( - views.get_edition(self.work.id), self.book) - - - def test_get_user_from_username(self): - ''' works for either localname or username ''' - self.assertEqual( - views.get_user_from_username('mouse'), self.local_user) - self.assertEqual( - views.get_user_from_username('mouse@local.com'), self.local_user) - with self.assertRaises(models.User.DoesNotExist): - views.get_user_from_username('mojfse@example.com') - - - def test_is_api_request(self): - ''' should it return html or json ''' - request = self.factory.get('/path') - request.headers = {'Accept': 'application/json'} - self.assertTrue(views.is_api_request(request)) - - request = self.factory.get('/path.json') - request.headers = {'Accept': 'Praise'} - self.assertTrue(views.is_api_request(request)) - - request = self.factory.get('/path') - request.headers = {'Accept': 'Praise'} - self.assertFalse(views.is_api_request(request)) - - - def test_home_tab(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - result = views.home_tab(request, 'local') - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'feed.html') - self.assertEqual(result.status_code, 200) - - - def test_direct_messages_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - result = views.direct_messages_page(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'direct_messages.html') - self.assertEqual(result.status_code, 200) - - - def test_get_activity_feed(self): - ''' loads statuses ''' - rat = models.User.objects.create_user( - 'rat', 'rat@rat.rat', 'password', local=True) - - public_status = models.Comment.objects.create( - content='public status', book=self.book, user=self.local_user) - direct_status = models.Status.objects.create( - content='direct', user=self.local_user, privacy='direct') - - rat_public = models.Status.objects.create( - content='blah blah', user=rat) - rat_unlisted = models.Status.objects.create( - content='blah blah', user=rat, privacy='unlisted') - remote_status = models.Status.objects.create( - content='blah blah', user=self.remote_user) - followers_status = models.Status.objects.create( - content='blah', user=rat, privacy='followers') - rat_mention = models.Status.objects.create( - content='blah blah blah', user=rat, privacy='followers') - rat_mention.mention_users.set([self.local_user]) - - statuses = views.get_activity_feed( - self.local_user, - ['public', 'unlisted', 'followers'], - following_only=True, - queryset=models.Comment.objects - ) - self.assertEqual(len(statuses), 1) - self.assertEqual(statuses[0], public_status) - - statuses = views.get_activity_feed( - self.local_user, - ['public', 'followers'], - local_only=True - ) - self.assertEqual(len(statuses), 2) - self.assertEqual(statuses[1], public_status) - self.assertEqual(statuses[0], rat_public) - - statuses = views.get_activity_feed(self.local_user, 'direct') - self.assertEqual(len(statuses), 1) - self.assertEqual(statuses[0], direct_status) - - statuses = views.get_activity_feed( - self.local_user, - ['public', 'followers'], - ) - self.assertEqual(len(statuses), 3) - self.assertEqual(statuses[2], public_status) - self.assertEqual(statuses[1], rat_public) - self.assertEqual(statuses[0], remote_status) - - statuses = views.get_activity_feed( - self.local_user, - ['public', 'unlisted', 'followers'], - following_only=True - ) - self.assertEqual(len(statuses), 2) - self.assertEqual(statuses[1], public_status) - self.assertEqual(statuses[0], rat_mention) - - rat.followers.add(self.local_user) - statuses = views.get_activity_feed( - self.local_user, - ['public', 'unlisted', 'followers'], - following_only=True - ) - self.assertEqual(len(statuses), 5) - self.assertEqual(statuses[4], public_status) - self.assertEqual(statuses[3], rat_public) - self.assertEqual(statuses[2], rat_unlisted) - self.assertEqual(statuses[1], followers_status) - self.assertEqual(statuses[0], rat_mention) - - - def test_search_json_response(self): - ''' searches local data only and returns book data in json format ''' - # we need a connector for this, sorry - request = self.factory.get('', {'q': 'Test Book'}) - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - response = views.search(request) - self.assertIsInstance(response, JsonResponse) - - data = json.loads(response.content) - self.assertEqual(len(data), 1) - self.assertEqual(data[0]['title'], 'Test Book') - self.assertEqual( - data[0]['key'], 'https://%s/book/%d' % (DOMAIN, self.book.id)) - - - def test_search_html_response(self): - ''' searches remote connectors ''' - class TestConnector(abstract_connector.AbstractMinimalConnector): - ''' nothing added here ''' - def format_search_result(self, search_result): - pass - def get_or_create_book(self, remote_id): - pass - def parse_search_data(self, data): - pass - models.Connector.objects.create( - identifier='example.com', - connector_file='openlibrary', - base_url='https://example.com', - books_url='https://example.com/books', - covers_url='https://example.com/covers', - search_url='https://example.com/search?q=', - ) - connector = TestConnector('example.com') - - search_result = abstract_connector.SearchResult( - key='http://www.example.com/book/1', - title='Gideon the Ninth', - author='Tamsyn Muir', - year='2019', - connector=connector - ) - - request = self.factory.get('', {'q': 'Test Book'}) - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - with patch( - 'bookwyrm.connectors.connector_manager.search') as manager: - manager.return_value = [search_result] - response = views.search(request) - self.assertIsInstance(response, TemplateResponse) - self.assertEqual(response.template_name, 'search_results.html') - self.assertEqual( - response.context_data['book_results'][0].title, 'Gideon the Ninth') - - - def test_search_html_response_users(self): - ''' searches remote connectors ''' - request = self.factory.get('', {'q': 'mouse'}) - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - with patch('bookwyrm.connectors.connector_manager.search'): - response = views.search(request) - self.assertIsInstance(response, TemplateResponse) - self.assertEqual(response.template_name, 'search_results.html') - self.assertEqual( - response.context_data['user_results'][0], self.local_user) - - - def test_import_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - result = views.import_page(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'import.html') - self.assertEqual(result.status_code, 200) - - - def test_import_status(self): - ''' there are so many views, this just makes sure it LOADS ''' - import_job = models.ImportJob.objects.create(user=self.local_user) - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.tasks.app.AsyncResult') as async_result: - async_result.return_value = [] - result = views.import_status(request, import_job.id) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'import_status.html') - self.assertEqual(result.status_code, 200) - - - def test_login_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = AnonymousUser - result = views.login_page(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'login.html') - self.assertEqual(result.status_code, 200) - - request.user = self.local_user - result = views.login_page(request) - self.assertEqual(result.url, '/') - self.assertEqual(result.status_code, 302) - - - def test_about_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - result = views.about_page(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'about.html') - self.assertEqual(result.status_code, 200) - - - def test_password_reset_request(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - result = views.password_reset_request(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'password_reset_request.html') - self.assertEqual(result.status_code, 200) - - - def test_password_reset(self): - ''' there are so many views, this just makes sure it LOADS ''' - code = models.PasswordReset.objects.create(user=self.local_user) - request = self.factory.get('') - request.user = AnonymousUser - result = views.password_reset(request, code.code) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'password_reset.html') - self.assertEqual(result.status_code, 200) - - - def test_invite_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - models.SiteInvite.objects.create(code='hi', user=self.local_user) - request = self.factory.get('') - request.user = AnonymousUser - # why?? this is annoying. - request.user.is_authenticated = False - with patch('bookwyrm.models.site.SiteInvite.valid') as invite: - invite.return_value = True - result = views.invite_page(request, 'hi') - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'invite.html') - self.assertEqual(result.status_code, 200) - - - def test_manage_invites(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - request.user.is_superuser = True - result = views.manage_invites(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'manage_invites.html') - self.assertEqual(result.status_code, 200) - - - def test_notifications_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - result = views.notifications_page(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'notifications.html') - self.assertEqual(result.status_code, 200) - - - def test_user_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.user_page(request, 'mouse') - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'user.html') - self.assertEqual(result.status_code, 200) - - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - result = views.user_page(request, 'mouse') - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_followers_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.followers_page(request, 'mouse') - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'followers.html') - self.assertEqual(result.status_code, 200) - - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - result = views.followers_page(request, 'mouse') - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_following_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.following_page(request, 'mouse') - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'following.html') - self.assertEqual(result.status_code, 200) - - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - result = views.following_page(request, 'mouse') - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_status_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - status = models.Status.objects.create( - content='hi', user=self.local_user) - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.status_page(request, 'mouse', status.id) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'status.html') - self.assertEqual(result.status_code, 200) - - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - result = views.status_page(request, 'mouse', status.id) - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_replies_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - status = models.Status.objects.create( - content='hi', user=self.local_user) - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.replies_page(request, 'mouse', status.id) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'status.html') - self.assertEqual(result.status_code, 200) - - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - result = views.replies_page(request, 'mouse', status.id) - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_edit_profile_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - result = views.edit_profile_page(request) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'edit_user.html') - self.assertEqual(result.status_code, 200) - - - def test_book_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.book_page(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.is_api_request') as is_api: - is_api.return_value = True - result = views.book_page(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 ''' - request = self.factory.get('') - request.user = self.local_user - request.user.is_superuser = True - result = views.edit_book_page(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_author_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - author = models.Author.objects.create(name='Test Author') - request = self.factory.get('') - request.user = self.local_user - request.user.is_superuser = True - result = views.edit_author_page(request, author.id) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'edit_author.html') - self.assertEqual(result.status_code, 200) - - - def test_editions_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - request = self.factory.get('') - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.editions_page(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.is_api_request') as is_api: - is_api.return_value = True - result = views.editions_page(request, self.work.id) - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_author_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - author = models.Author.objects.create(name='Jessica') - request = self.factory.get('') - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.author_page(request, author.id) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'author.html') - self.assertEqual(result.status_code, 200) - - request = self.factory.get('') - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - result = views.author_page(request, author.id) - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_tag_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - tag = models.Tag.objects.create(name='hi there') - models.UserTag.objects.create( - tag=tag, user=self.local_user, book=self.book) - request = self.factory.get('') - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.tag_page(request, tag.identifier) - self.assertIsInstance(result, TemplateResponse) - self.assertEqual(result.template_name, 'tag.html') - self.assertEqual(result.status_code, 200) - - request = self.factory.get('') - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = True - result = views.tag_page(request, tag.identifier) - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_shelf_page(self): - ''' there are so many views, this just makes sure it LOADS ''' - shelf = self.local_user.shelf_set.first() - request = self.factory.get('') - request.user = self.local_user - with patch('bookwyrm.views.is_api_request') as is_api: - is_api.return_value = False - result = views.shelf_page( - 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.is_api_request') as is_api: - is_api.return_value = True - result = views.shelf_page( - 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.is_api_request') as is_api: - is_api.return_value = True - result = views.shelf_page( - request, self.local_user.username, shelf.identifier) - self.assertIsInstance(result, ActivitypubResponse) - self.assertEqual(result.status_code, 200) - - - def test_is_bookwyrm_request(self): - ''' checks if a request came from a bookwyrm instance ''' - request = self.factory.get('', {'q': 'Test Book'}) - self.assertFalse(views.is_bookworm_request(request)) - - request = self.factory.get( - '', {'q': 'Test Book'}, - HTTP_USER_AGENT=\ - "http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)" - ) - self.assertFalse(views.is_bookworm_request(request)) - - request = self.factory.get( - '', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT) - self.assertTrue(views.is_bookworm_request(request)) diff --git a/bookwyrm/tests/views/test_authentication.py b/bookwyrm/tests/views/test_authentication.py new file mode 100644 index 000000000..b0d099832 --- /dev/null +++ b/bookwyrm/tests/views/test_authentication.py @@ -0,0 +1,302 @@ +''' test for app action functionality ''' +from unittest.mock import patch + +from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import PermissionDenied +from django.http.response import Http404 +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views +from bookwyrm.settings import DOMAIN + + +# pylint: disable=too-many-public-methods +class AuthenticationViews(TestCase): + ''' login and password management ''' + 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', 'password', + local=True, localname='mouse') + self.anonymous_user = AnonymousUser + self.anonymous_user.is_authenticated = False + self.settings = models.SiteSettings.objects.create(id=1) + + def test_login_get(self): + ''' there are so many views, this just makes sure it LOADS ''' + login = views.Login.as_view() + request = self.factory.get('') + request.user = self.anonymous_user + + result = login(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'login.html') + self.assertEqual(result.status_code, 200) + + request.user = self.local_user + result = login(request) + self.assertEqual(result.url, '/') + self.assertEqual(result.status_code, 302) + + + def test_password_reset_request(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.PasswordResetRequest.as_view() + request = self.factory.get('') + request.user = self.local_user + + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'password_reset_request.html') + self.assertEqual(result.status_code, 200) + + + def test_password_reset_request_post(self): + ''' send 'em an email ''' + request = self.factory.post('', {'email': 'aa@bb.ccc'}) + view = views.PasswordResetRequest.as_view() + resp = view(request) + self.assertEqual(resp.status_code, 302) + + request = self.factory.post('', {'email': 'mouse@mouse.com'}) + with patch('bookwyrm.emailing.send_email.delay'): + resp = view(request) + self.assertEqual(resp.template_name, 'password_reset_request.html') + + self.assertEqual( + models.PasswordReset.objects.get().user, self.local_user) + + def test_password_reset(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.get('') + request.user = self.anonymous_user + result = view(request, code.code) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'password_reset.html') + self.assertEqual(result.status_code, 200) + + + def test_password_reset_post(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + with patch('bookwyrm.views.password.login'): + resp = view(request, code.code) + self.assertEqual(resp.status_code, 302) + self.assertFalse(models.PasswordReset.objects.exists()) + + def test_password_reset_wrong_code(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + resp = view(request, 'jhgdkfjgdf') + self.assertEqual(resp.template_name, 'password_reset.html') + self.assertTrue(models.PasswordReset.objects.exists()) + + def test_password_reset_mismatch(self): + ''' reset from code ''' + view = views.PasswordReset.as_view() + code = models.PasswordReset.objects.create(user=self.local_user) + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hihi' + }) + resp = view(request, code.code) + self.assertEqual(resp.template_name, 'password_reset.html') + self.assertTrue(models.PasswordReset.objects.exists()) + + + def test_register(self): + ''' create a user ''' + view = views.Register.as_view() + self.assertEqual(models.User.objects.count(), 1) + request = self.factory.post( + 'register/', + { + 'localname': 'nutria-user.user_nutria', + 'password': 'mouseword', + 'email': 'aa@bb.cccc' + }) + with patch('bookwyrm.views.authentication.login'): + response = view(request) + self.assertEqual(models.User.objects.count(), 2) + self.assertEqual(response.status_code, 302) + nutria = models.User.objects.last() + self.assertEqual(nutria.username, 'nutria-user.user_nutria@%s' % DOMAIN) + self.assertEqual(nutria.localname, 'nutria-user.user_nutria') + self.assertEqual(nutria.local, True) + + def test_register_trailing_space(self): + ''' django handles this so weirdly ''' + view = views.Register.as_view() + request = self.factory.post( + 'register/', + { + 'localname': 'nutria ', + 'password': 'mouseword', + 'email': 'aa@bb.ccc' + }) + with patch('bookwyrm.views.authentication.login'): + response = view(request) + self.assertEqual(models.User.objects.count(), 2) + self.assertEqual(response.status_code, 302) + nutria = models.User.objects.last() + self.assertEqual(nutria.username, 'nutria@%s' % DOMAIN) + self.assertEqual(nutria.localname, 'nutria') + self.assertEqual(nutria.local, True) + + def test_register_invalid_email(self): + ''' gotta have an email ''' + view = views.Register.as_view() + self.assertEqual(models.User.objects.count(), 1) + request = self.factory.post( + 'register/', + { + 'localname': 'nutria', + 'password': 'mouseword', + 'email': 'aa' + }) + response = view(request) + self.assertEqual(models.User.objects.count(), 1) + self.assertEqual(response.template_name, 'login.html') + + def test_register_invalid_username(self): + ''' gotta have an email ''' + view = views.Register.as_view() + self.assertEqual(models.User.objects.count(), 1) + request = self.factory.post( + 'register/', + { + 'localname': 'nut@ria', + 'password': 'mouseword', + 'email': 'aa@bb.ccc' + }) + response = view(request) + self.assertEqual(models.User.objects.count(), 1) + self.assertEqual(response.template_name, 'login.html') + + request = self.factory.post( + 'register/', + { + 'localname': 'nutr ia', + 'password': 'mouseword', + 'email': 'aa@bb.ccc' + }) + response = view(request) + self.assertEqual(models.User.objects.count(), 1) + self.assertEqual(response.template_name, 'login.html') + + request = self.factory.post( + 'register/', + { + 'localname': 'nut@ria', + 'password': 'mouseword', + 'email': 'aa@bb.ccc' + }) + response = view(request) + self.assertEqual(models.User.objects.count(), 1) + self.assertEqual(response.template_name, 'login.html') + + + def test_register_closed_instance(self): + ''' you can't just register ''' + view = views.Register.as_view() + self.settings.allow_registration = False + self.settings.save() + request = self.factory.post( + 'register/', + { + 'localname': 'nutria ', + 'password': 'mouseword', + 'email': 'aa@bb.ccc' + }) + with self.assertRaises(PermissionDenied): + view(request) + + def test_register_invite(self): + ''' you can't just register ''' + view = views.Register.as_view() + self.settings.allow_registration = False + self.settings.save() + models.SiteInvite.objects.create( + code='testcode', user=self.local_user, use_limit=1) + self.assertEqual(models.SiteInvite.objects.get().times_used, 0) + + request = self.factory.post( + 'register/', + { + 'localname': 'nutria', + 'password': 'mouseword', + 'email': 'aa@bb.ccc', + 'invite_code': 'testcode' + }) + with patch('bookwyrm.views.authentication.login'): + response = view(request) + self.assertEqual(models.User.objects.count(), 2) + self.assertEqual(response.status_code, 302) + self.assertEqual(models.SiteInvite.objects.get().times_used, 1) + + # invite already used to max capacity + request = self.factory.post( + 'register/', + { + 'localname': 'nutria2', + 'password': 'mouseword', + 'email': 'aa@bb.ccc', + 'invite_code': 'testcode' + }) + with self.assertRaises(PermissionDenied): + response = view(request) + self.assertEqual(models.User.objects.count(), 2) + + # bad invite code + request = self.factory.post( + 'register/', + { + 'localname': 'nutria3', + 'password': 'mouseword', + 'email': 'aa@bb.ccc', + 'invite_code': 'dkfkdjgdfkjgkdfj' + }) + with self.assertRaises(Http404): + response = view(request) + self.assertEqual(models.User.objects.count(), 2) + + + def test_password_change(self): + ''' change password ''' + view = views.ChangePassword.as_view() + password_hash = self.local_user.password + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hi' + }) + request.user = self.local_user + with patch('bookwyrm.views.password.login'): + view(request) + self.assertNotEqual(self.local_user.password, password_hash) + + def test_password_change_mismatch(self): + ''' change password ''' + view = views.ChangePassword.as_view() + password_hash = self.local_user.password + request = self.factory.post('', { + 'password': 'hi', + 'confirm-password': 'hihi' + }) + request.user = self.local_user + view(request) + self.assertEqual(self.local_user.password, password_hash) diff --git a/bookwyrm/tests/views/test_author.py b/bookwyrm/tests/views/test_author.py new file mode 100644 index 000000000..c00972f39 --- /dev/null +++ b/bookwyrm/tests/views/test_author.py @@ -0,0 +1,119 @@ +''' 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.core.exceptions import PermissionDenied +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 AuthorViews(TestCase): + ''' author 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.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_author_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Author.as_view() + author = models.Author.objects.create(name='Jessica') + request = self.factory.get('') + with patch('bookwyrm.views.author.is_api_request') as is_api: + is_api.return_value = False + result = view(request, author.id) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'author.html') + self.assertEqual(result.status_code, 200) + + request = self.factory.get('') + with patch('bookwyrm.views.author.is_api_request') as is_api: + is_api.return_value = True + result = view(request, author.id) + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + + def test_edit_author_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.EditAuthor.as_view() + author = models.Author.objects.create(name='Test Author') + request = self.factory.get('') + request.user = self.local_user + request.user.is_superuser = True + + result = view(request, author.id) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'edit_author.html') + self.assertEqual(result.status_code, 200) + + + def test_edit_author(self): + ''' edit an author ''' + view = views.EditAuthor.as_view() + author = models.Author.objects.create(name='Test Author') + self.local_user.groups.add(self.group) + form = forms.AuthorForm(instance=author) + form.data['name'] = 'New Name' + 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, author.id) + author.refresh_from_db() + self.assertEqual(author.name, 'New Name') + self.assertEqual(author.last_edited_by, self.local_user) + + def test_edit_author_non_editor(self): + ''' edit an author with invalid post data''' + view = views.EditAuthor.as_view() + author = models.Author.objects.create(name='Test Author') + form = forms.AuthorForm(instance=author) + form.data['name'] = 'New Name' + form.data['last_edited_by'] = self.local_user.id + request = self.factory.post('', form.data) + request.user = self.local_user + + with self.assertRaises(PermissionDenied): + view(request, author.id) + author.refresh_from_db() + self.assertEqual(author.name, 'Test Author') + + def test_edit_author_invalid_form(self): + ''' edit an author with invalid post data''' + view = views.EditAuthor.as_view() + author = models.Author.objects.create(name='Test Author') + self.local_user.groups.add(self.group) + form = forms.AuthorForm(instance=author) + form.data['name'] = '' + form.data['last_edited_by'] = self.local_user.id + request = self.factory.post('', form.data) + request.user = self.local_user + + resp = view(request, author.id) + author.refresh_from_db() + self.assertEqual(author.name, 'Test Author') + self.assertEqual(resp.template_name, 'edit_author.html') diff --git a/bookwyrm/tests/views/test_book.py b/bookwyrm/tests/views/test_book.py new file mode 100644 index 000000000..8306b8037 --- /dev/null +++ b/bookwyrm/tests/views/test_book.py @@ -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 BookViews(TestCase): + ''' books books books ''' + 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) diff --git a/bookwyrm/tests/views/test_direct_message.py b/bookwyrm/tests/views/test_direct_message.py new file mode 100644 index 000000000..48820c755 --- /dev/null +++ b/bookwyrm/tests/views/test_direct_message.py @@ -0,0 +1,28 @@ +''' test for app action functionality ''' +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models +from bookwyrm import views + + +class DirectMessageViews(TestCase): + ''' dms ''' + 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.mouse', 'password', + local=True, localname='mouse') + + + def test_direct_messages_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.DirectMessage.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'direct_messages.html') + self.assertEqual(result.status_code, 200) diff --git a/bookwyrm/tests/views/test_follow.py b/bookwyrm/tests/views/test_follow.py new file mode 100644 index 000000000..9bbf67fbb --- /dev/null +++ b/bookwyrm/tests/views/test_follow.py @@ -0,0 +1,108 @@ +''' 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.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views + + +class BookViews(TestCase): + ''' books books books ''' + 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', + ) + with patch('bookwyrm.models.user.set_remote_server'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@email.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + 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_handle_follow(self): + ''' send a follow request ''' + request = self.factory.post('', {'user': self.remote_user.username}) + request.user = self.local_user + self.assertEqual(models.UserFollowRequest.objects.count(), 0) + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.follow(request) + + rel = models.UserFollowRequest.objects.get() + + self.assertEqual(rel.user_subject, self.local_user) + self.assertEqual(rel.user_object, self.remote_user) + self.assertEqual(rel.status, 'follow_request') + + + def test_handle_unfollow(self): + ''' send an unfollow ''' + request = self.factory.post('', {'user': self.remote_user.username}) + request.user = self.local_user + self.remote_user.followers.add(self.local_user) + self.assertEqual(self.remote_user.followers.count(), 1) + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.unfollow(request) + + self.assertEqual(self.remote_user.followers.count(), 0) + + + def test_handle_accept(self): + ''' accept a follow request ''' + request = self.factory.post('', {'user': self.remote_user.username}) + request.user = self.local_user + rel = models.UserFollowRequest.objects.create( + user_subject=self.remote_user, + user_object=self.local_user + ) + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.accept_follow_request(request) + # request should be deleted + self.assertEqual( + models.UserFollowRequest.objects.filter(id=rel.id).count(), 0 + ) + # follow relationship should exist + self.assertEqual(self.local_user.followers.first(), self.remote_user) + + + def test_handle_reject(self): + ''' reject a follow request ''' + request = self.factory.post('', {'user': self.remote_user.username}) + request.user = self.local_user + rel = models.UserFollowRequest.objects.create( + user_subject=self.remote_user, + user_object=self.local_user + ) + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.delete_follow_request(request) + # request should be deleted + self.assertEqual( + models.UserFollowRequest.objects.filter(id=rel.id).count(), 0 + ) + # follow relationship should not exist + self.assertEqual( + models.UserFollows.objects.filter(id=rel.id).count(), 0 + ) diff --git a/bookwyrm/tests/views/test_helpers.py b/bookwyrm/tests/views/test_helpers.py new file mode 100644 index 000000000..bd8928962 --- /dev/null +++ b/bookwyrm/tests/views/test_helpers.py @@ -0,0 +1,250 @@ +''' test for app action functionality ''' +import json +from unittest.mock import patch +import pathlib +from django.test import TestCase +from django.test.client import RequestFactory +import responses + +from bookwyrm import models, views +from bookwyrm.settings import USER_AGENT + +class ViewsHelpers(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.work = models.Work.objects.create(title='Test Work') + self.book = models.Edition.objects.create( + title='Test Book', + remote_id='https://example.com/book/1', + parent_work=self.work + ) + with patch('bookwyrm.models.user.set_remote_server.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + datafile = pathlib.Path(__file__).parent.joinpath( + '../data/ap_user.json' + ) + self.userdata = json.loads(datafile.read_bytes()) + del self.userdata['icon'] + self.shelf = models.Shelf.objects.create( + name='Test Shelf', + identifier='test-shelf', + user=self.local_user + ) + + + def test_get_edition(self): + ''' given an edition or a work, returns an edition ''' + self.assertEqual( + views.helpers.get_edition(self.book.id), self.book) + self.assertEqual( + views.helpers.get_edition(self.work.id), self.book) + + def test_get_user_from_username(self): + ''' works for either localname or username ''' + self.assertEqual( + views.helpers.get_user_from_username('mouse'), self.local_user) + self.assertEqual( + views.helpers.get_user_from_username( + 'mouse@local.com'), self.local_user) + with self.assertRaises(models.User.DoesNotExist): + views.helpers.get_user_from_username('mojfse@example.com') + + + def test_is_api_request(self): + ''' should it return html or json ''' + request = self.factory.get('/path') + request.headers = {'Accept': 'application/json'} + self.assertTrue(views.helpers.is_api_request(request)) + + request = self.factory.get('/path.json') + request.headers = {'Accept': 'Praise'} + self.assertTrue(views.helpers.is_api_request(request)) + + request = self.factory.get('/path') + request.headers = {'Accept': 'Praise'} + self.assertFalse(views.helpers.is_api_request(request)) + + + def test_get_activity_feed(self): + ''' loads statuses ''' + rat = models.User.objects.create_user( + 'rat', 'rat@rat.rat', 'password', local=True) + + public_status = models.Comment.objects.create( + content='public status', book=self.book, user=self.local_user) + direct_status = models.Status.objects.create( + content='direct', user=self.local_user, privacy='direct') + + rat_public = models.Status.objects.create( + content='blah blah', user=rat) + rat_unlisted = models.Status.objects.create( + content='blah blah', user=rat, privacy='unlisted') + remote_status = models.Status.objects.create( + content='blah blah', user=self.remote_user) + followers_status = models.Status.objects.create( + content='blah', user=rat, privacy='followers') + rat_mention = models.Status.objects.create( + content='blah blah blah', user=rat, privacy='followers') + rat_mention.mention_users.set([self.local_user]) + + statuses = views.helpers.get_activity_feed( + self.local_user, + ['public', 'unlisted', 'followers'], + following_only=True, + queryset=models.Comment.objects + ) + self.assertEqual(len(statuses), 1) + self.assertEqual(statuses[0], public_status) + + statuses = views.helpers.get_activity_feed( + self.local_user, + ['public', 'followers'], + local_only=True + ) + self.assertEqual(len(statuses), 2) + self.assertEqual(statuses[1], public_status) + self.assertEqual(statuses[0], rat_public) + + statuses = views.helpers.get_activity_feed(self.local_user, 'direct') + self.assertEqual(len(statuses), 1) + self.assertEqual(statuses[0], direct_status) + + statuses = views.helpers.get_activity_feed( + self.local_user, + ['public', 'followers'], + ) + self.assertEqual(len(statuses), 3) + self.assertEqual(statuses[2], public_status) + self.assertEqual(statuses[1], rat_public) + self.assertEqual(statuses[0], remote_status) + + statuses = views.helpers.get_activity_feed( + self.local_user, + ['public', 'unlisted', 'followers'], + following_only=True + ) + self.assertEqual(len(statuses), 2) + self.assertEqual(statuses[1], public_status) + self.assertEqual(statuses[0], rat_mention) + + rat.followers.add(self.local_user) + statuses = views.helpers.get_activity_feed( + self.local_user, + ['public', 'unlisted', 'followers'], + following_only=True + ) + self.assertEqual(len(statuses), 5) + self.assertEqual(statuses[4], public_status) + self.assertEqual(statuses[3], rat_public) + self.assertEqual(statuses[2], rat_unlisted) + self.assertEqual(statuses[1], followers_status) + self.assertEqual(statuses[0], rat_mention) + + + def test_is_bookwyrm_request(self): + ''' checks if a request came from a bookwyrm instance ''' + request = self.factory.get('', {'q': 'Test Book'}) + self.assertFalse(views.helpers.is_bookworm_request(request)) + + request = self.factory.get( + '', {'q': 'Test Book'}, + HTTP_USER_AGENT=\ + "http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)" + ) + self.assertFalse(views.helpers.is_bookworm_request(request)) + + request = self.factory.get( + '', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT) + self.assertTrue(views.helpers.is_bookworm_request(request)) + + + def test_existing_user(self): + ''' simple database lookup by username ''' + result = views.helpers.handle_remote_webfinger('@mouse@local.com') + self.assertEqual(result, self.local_user) + + result = views.helpers.handle_remote_webfinger('mouse@local.com') + self.assertEqual(result, self.local_user) + + + @responses.activate + def test_load_user(self): + ''' find a remote user using webfinger ''' + username = 'mouse@example.com' + wellknown = { + "subject": "acct:mouse@example.com", + "links": [{ + "rel": "self", + "type": "application/activity+json", + "href": "https://example.com/user/mouse" + }] + } + responses.add( + responses.GET, + 'https://example.com/.well-known/webfinger?resource=acct:%s' \ + % username, + json=wellknown, + status=200) + responses.add( + responses.GET, + 'https://example.com/user/mouse', + json=self.userdata, + status=200) + with patch('bookwyrm.models.user.set_remote_server.delay'): + result = views.helpers.handle_remote_webfinger('@mouse@example.com') + self.assertIsInstance(result, models.User) + self.assertEqual(result.username, 'mouse@example.com') + + + def test_handle_reading_status_to_read(self): + ''' posts shelve activities ''' + shelf = self.local_user.shelf_set.get(identifier='to-read') + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.helpers.handle_reading_status( + self.local_user, shelf, self.book, 'public') + status = models.GeneratedNote.objects.get() + self.assertEqual(status.user, self.local_user) + self.assertEqual(status.mention_books.first(), self.book) + self.assertEqual(status.content, 'wants to read') + + def test_handle_reading_status_reading(self): + ''' posts shelve activities ''' + shelf = self.local_user.shelf_set.get(identifier='reading') + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.helpers.handle_reading_status( + self.local_user, shelf, self.book, 'public') + status = models.GeneratedNote.objects.get() + self.assertEqual(status.user, self.local_user) + self.assertEqual(status.mention_books.first(), self.book) + self.assertEqual(status.content, 'started reading') + + def test_handle_reading_status_read(self): + ''' posts shelve activities ''' + shelf = self.local_user.shelf_set.get(identifier='read') + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.helpers.handle_reading_status( + self.local_user, shelf, self.book, 'public') + status = models.GeneratedNote.objects.get() + self.assertEqual(status.user, self.local_user) + self.assertEqual(status.mention_books.first(), self.book) + self.assertEqual(status.content, 'finished reading') + + def test_handle_reading_status_other(self): + ''' posts shelve activities ''' + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.helpers.handle_reading_status( + self.local_user, self.shelf, self.book, 'public') + self.assertFalse(models.GeneratedNote.objects.exists()) diff --git a/bookwyrm/tests/views/test_import.py b/bookwyrm/tests/views/test_import.py new file mode 100644 index 000000000..14209f24b --- /dev/null +++ b/bookwyrm/tests/views/test_import.py @@ -0,0 +1,43 @@ +''' 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 +from bookwyrm import views + + +class ImportViews(TestCase): + ''' goodreads import 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.mouse', 'password', + local=True, localname='mouse') + + + def test_import_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Import.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'import.html') + self.assertEqual(result.status_code, 200) + + + def test_import_status(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.ImportStatus.as_view() + import_job = models.ImportJob.objects.create(user=self.local_user) + request = self.factory.get('') + request.user = self.local_user + with patch('bookwyrm.tasks.app.AsyncResult') as async_result: + async_result.return_value = [] + result = view(request, import_job.id) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'import_status.html') + self.assertEqual(result.status_code, 200) diff --git a/bookwyrm/tests/views/test_interaction.py b/bookwyrm/tests/views/test_interaction.py new file mode 100644 index 000000000..da1fa90dd --- /dev/null +++ b/bookwyrm/tests/views/test_interaction.py @@ -0,0 +1,152 @@ +''' test for app action functionality ''' +from unittest.mock import patch +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views + + +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', + ) + with patch('bookwyrm.models.user.set_remote_server'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@email.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + + 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=work + ) + + + def test_handle_favorite(self): + ''' create and broadcast faving a status ''' + view = views.Favorite.as_view() + request = self.factory.post('') + request.user = self.remote_user + status = models.Status.objects.create( + user=self.local_user, content='hi') + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + fav = models.Favorite.objects.get() + self.assertEqual(fav.status, status) + self.assertEqual(fav.user, self.remote_user) + + notification = models.Notification.objects.get() + self.assertEqual(notification.notification_type, 'FAVORITE') + self.assertEqual(notification.user, self.local_user) + self.assertEqual(notification.related_user, self.remote_user) + + + def test_handle_unfavorite(self): + ''' unfav a status ''' + view = views.Unfavorite.as_view() + request = self.factory.post('') + request.user = self.remote_user + status = models.Status.objects.create( + user=self.local_user, content='hi') + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.Favorite.as_view()(request, status.id) + + self.assertEqual(models.Favorite.objects.count(), 1) + self.assertEqual(models.Notification.objects.count(), 1) + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + self.assertEqual(models.Favorite.objects.count(), 0) + self.assertEqual(models.Notification.objects.count(), 0) + + + def test_handle_boost(self): + ''' boost a status ''' + view = views.Boost.as_view() + request = self.factory.post('') + request.user = self.remote_user + status = models.Status.objects.create( + user=self.local_user, content='hi') + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + + boost = models.Boost.objects.get() + self.assertEqual(boost.boosted_status, status) + self.assertEqual(boost.user, self.remote_user) + self.assertEqual(boost.privacy, 'public') + + notification = models.Notification.objects.get() + self.assertEqual(notification.notification_type, 'BOOST') + self.assertEqual(notification.user, self.local_user) + self.assertEqual(notification.related_user, self.remote_user) + self.assertEqual(notification.related_status, status) + + def test_handle_boost_unlisted(self): + ''' boost a status ''' + view = views.Boost.as_view() + request = self.factory.post('') + request.user = self.local_user + status = models.Status.objects.create( + user=self.local_user, content='hi', privacy='unlisted') + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + + boost = models.Boost.objects.get() + self.assertEqual(boost.privacy, 'unlisted') + + def test_handle_boost_private(self): + ''' boost a status ''' + view = views.Boost.as_view() + request = self.factory.post('') + request.user = self.local_user + status = models.Status.objects.create( + user=self.local_user, content='hi', privacy='followers') + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + self.assertFalse(models.Boost.objects.exists()) + + def test_handle_boost_twice(self): + ''' boost a status ''' + view = views.Boost.as_view() + request = self.factory.post('') + request.user = self.local_user + status = models.Status.objects.create( + user=self.local_user, content='hi') + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + view(request, status.id) + self.assertEqual(models.Boost.objects.count(), 1) + + + def test_handle_unboost(self): + ''' undo a boost ''' + view = views.Unboost.as_view() + request = self.factory.post('') + request.user = self.remote_user + status = models.Status.objects.create( + user=self.local_user, content='hi') + with patch('bookwyrm.broadcast.broadcast_task.delay'): + views.Boost.as_view()(request, status.id) + + self.assertEqual(models.Boost.objects.count(), 1) + self.assertEqual(models.Notification.objects.count(), 1) + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + self.assertEqual(models.Boost.objects.count(), 0) + self.assertEqual(models.Notification.objects.count(), 0) diff --git a/bookwyrm/tests/views/test_invite.py b/bookwyrm/tests/views/test_invite.py new file mode 100644 index 000000000..57b7a34ae --- /dev/null +++ b/bookwyrm/tests/views/test_invite.py @@ -0,0 +1,48 @@ +''' test for app action functionality ''' +from unittest.mock import patch + +from django.contrib.auth.models import AnonymousUser +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models +from bookwyrm import views + + +class InviteViews(TestCase): + ''' every response to a get request, html or json ''' + 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.mouse', 'password', + local=True, localname='mouse') + + + def test_invite_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Invite.as_view() + models.SiteInvite.objects.create(code='hi', user=self.local_user) + request = self.factory.get('') + request.user = AnonymousUser + # why?? this is annoying. + request.user.is_authenticated = False + with patch('bookwyrm.models.site.SiteInvite.valid') as invite: + invite.return_value = True + result = view(request, 'hi') + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'invite.html') + self.assertEqual(result.status_code, 200) + + + def test_manage_invites(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.ManageInvites.as_view() + request = self.factory.get('') + request.user = self.local_user + request.user.is_superuser = True + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'manage_invites.html') + self.assertEqual(result.status_code, 200) diff --git a/bookwyrm/tests/views/test_landing.py b/bookwyrm/tests/views/test_landing.py new file mode 100644 index 000000000..5596b4f35 --- /dev/null +++ b/bookwyrm/tests/views/test_landing.py @@ -0,0 +1,84 @@ +''' test for app action functionality ''' +from django.contrib.auth.models import AnonymousUser +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models +from bookwyrm import views + + +class LandingViews(TestCase): + ''' pages you land on without really trying ''' + 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.mouse', 'password', + local=True, localname='mouse') + self.anonymous_user = AnonymousUser + self.anonymous_user.is_authenticated = False + self.book = models.Edition.objects.create( + title='Example Edition', + remote_id='https://example.com/book/1', + ) + + + def test_home_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Home.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.template_name, 'feed.html') + + request.user = self.anonymous_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.status_code, 200) + self.assertEqual(result.template_name, 'discover.html') + + + def test_about_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.About.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'about.html') + self.assertEqual(result.status_code, 200) + + + def test_feed(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Feed.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request, 'local') + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'feed.html') + self.assertEqual(result.status_code, 200) + + + def test_discover(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Discover.as_view() + request = self.factory.get('') + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'discover.html') + self.assertEqual(result.status_code, 200) + + + def test_get_suggested_book(self): + ''' gets books the ~*~ algorithm ~*~ thinks you want to post about ''' + models.ShelfBook.objects.create( + book=self.book, + added_by=self.local_user, + shelf=self.local_user.shelf_set.get(identifier='reading') + ) + suggestions = views.landing.get_suggested_books(self.local_user) + self.assertEqual(suggestions[0]['name'], 'Currently Reading') + self.assertEqual(suggestions[0]['books'][0], self.book) diff --git a/bookwyrm/tests/views/test_notifications.py b/bookwyrm/tests/views/test_notifications.py new file mode 100644 index 000000000..683424d5a --- /dev/null +++ b/bookwyrm/tests/views/test_notifications.py @@ -0,0 +1,41 @@ +''' test for app action functionality ''' +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models +from bookwyrm import views + + +class NotificationViews(TestCase): + ''' notifications ''' + 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.mouse', 'password', + local=True, localname='mouse') + + def test_notifications_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Notifications.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'notifications.html') + self.assertEqual(result.status_code, 200) + + def test_clear_notifications(self): + ''' erase notifications ''' + models.Notification.objects.create( + user=self.local_user, notification_type='MENTION') + models.Notification.objects.create( + user=self.local_user, notification_type='MENTION', read=True) + self.assertEqual(models.Notification.objects.count(), 2) + view = views.Notifications.as_view() + request = self.factory.post('') + request.user = self.local_user + result = view(request) + self.assertEqual(result.status_code, 302) + self.assertEqual(models.Notification.objects.count(), 1) diff --git a/bookwyrm/tests/views/test_outbox.py b/bookwyrm/tests/views/test_outbox.py new file mode 100644 index 000000000..4b47d7ac2 --- /dev/null +++ b/bookwyrm/tests/views/test_outbox.py @@ -0,0 +1,88 @@ +''' sending out activities ''' +import json + +from django.http import JsonResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views + + +# pylint: disable=too-many-public-methods +class OutboxView(TestCase): + ''' sends out activities ''' + def setUp(self): + ''' we'll need some data ''' + 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', + ) + 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=work + ) + + + def test_outbox(self): + ''' returns user's statuses ''' + request = self.factory.get('') + result = views.Outbox.as_view()(request, 'mouse') + self.assertIsInstance(result, JsonResponse) + + def test_outbox_bad_method(self): + ''' can't POST to outbox ''' + request = self.factory.post('') + result = views.Outbox.as_view()(request, 'mouse') + self.assertEqual(result.status_code, 405) + + def test_outbox_unknown_user(self): + ''' should 404 for unknown and remote users ''' + request = self.factory.post('') + result = views.Outbox.as_view()(request, 'beepboop') + self.assertEqual(result.status_code, 405) + result = views.Outbox.as_view()(request, 'rat') + self.assertEqual(result.status_code, 405) + + def test_outbox_privacy(self): + ''' don't show dms et cetera in outbox ''' + models.Status.objects.create( + content='PRIVATE!!', user=self.local_user, privacy='direct') + models.Status.objects.create( + content='bffs ONLY', user=self.local_user, privacy='followers') + models.Status.objects.create( + content='unlisted status', user=self.local_user, privacy='unlisted') + models.Status.objects.create( + content='look at this', user=self.local_user, privacy='public') + + request = self.factory.get('') + result = views.Outbox.as_view()(request, 'mouse') + self.assertIsInstance(result, JsonResponse) + data = json.loads(result.content) + self.assertEqual(data['type'], 'OrderedCollection') + self.assertEqual(data['totalItems'], 2) + + def test_outbox_filter(self): + ''' if we only care about reviews, only get reviews ''' + models.Review.objects.create( + content='look at this', name='hi', rating=1, + book=self.book, user=self.local_user) + models.Status.objects.create( + content='look at this', user=self.local_user) + + request = self.factory.get('', {'type': 'bleh'}) + result = views.Outbox.as_view()(request, 'mouse') + self.assertIsInstance(result, JsonResponse) + data = json.loads(result.content) + self.assertEqual(data['type'], 'OrderedCollection') + self.assertEqual(data['totalItems'], 2) + + request = self.factory.get('', {'type': 'Review'}) + result = views.Outbox.as_view()(request, 'mouse') + self.assertIsInstance(result, JsonResponse) + data = json.loads(result.content) + self.assertEqual(data['type'], 'OrderedCollection') + self.assertEqual(data['totalItems'], 1) diff --git a/bookwyrm/tests/views/test_reading.py b/bookwyrm/tests/views/test_reading.py new file mode 100644 index 000000000..436dbf68e --- /dev/null +++ b/bookwyrm/tests/views/test_reading.py @@ -0,0 +1,176 @@ +''' test for app action functionality ''' +from unittest.mock import patch +import dateutil +from django.test import TestCase +from django.test.client import RequestFactory +from django.utils import timezone + +from bookwyrm import models, views + +class ReadingViews(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.work = models.Work.objects.create(title='Test Work') + self.book = models.Edition.objects.create( + title='Test Book', + remote_id='https://example.com/book/1', + parent_work=self.work + ) + with patch('bookwyrm.models.user.set_remote_server.delay'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + + + def test_start_reading(self): + ''' begin a book ''' + shelf = self.local_user.shelf_set.get(identifier='reading') + self.assertFalse(shelf.books.exists()) + self.assertFalse(models.Status.objects.exists()) + + request = self.factory.post('', { + 'post-status': True, + 'privacy': 'followers', + 'start_date': '2020-01-05', + }) + request.user = self.local_user + views.start_reading(request, self.book.id) + + self.assertEqual(shelf.books.get(), self.book) + + status = models.GeneratedNote.objects.get() + self.assertEqual(status.user, self.local_user) + self.assertEqual(status.mention_books.get(), self.book) + self.assertEqual(status.privacy, 'followers') + + readthrough = models.ReadThrough.objects.get() + self.assertIsNotNone(readthrough.start_date) + self.assertIsNone(readthrough.finish_date) + self.assertEqual(readthrough.user, self.local_user) + self.assertEqual(readthrough.book, self.book) + + + def test_start_reading_reshelf(self): + ''' begin a book ''' + to_read_shelf = self.local_user.shelf_set.get(identifier='to-read') + models.ShelfBook.objects.create( + shelf=to_read_shelf, book=self.book, added_by=self.local_user) + shelf = self.local_user.shelf_set.get(identifier='reading') + self.assertEqual(to_read_shelf.books.get(), self.book) + self.assertFalse(shelf.books.exists()) + self.assertFalse(models.Status.objects.exists()) + + request = self.factory.post('') + request.user = self.local_user + views.start_reading(request, self.book.id) + + self.assertFalse(to_read_shelf.books.exists()) + self.assertEqual(shelf.books.get(), self.book) + + def test_finish_reading(self): + ''' begin a book ''' + shelf = self.local_user.shelf_set.get(identifier='read') + self.assertFalse(shelf.books.exists()) + self.assertFalse(models.Status.objects.exists()) + readthrough = models.ReadThrough.objects.create( + user=self.local_user, + start_date=timezone.now(), + book=self.book) + + request = self.factory.post('', { + 'post-status': True, + 'privacy': 'followers', + 'finish_date': '2020-01-07', + 'id': readthrough.id, + }) + request.user = self.local_user + views.finish_reading(request, self.book.id) + + self.assertEqual(shelf.books.get(), self.book) + + status = models.GeneratedNote.objects.get() + self.assertEqual(status.user, self.local_user) + self.assertEqual(status.mention_books.get(), self.book) + self.assertEqual(status.privacy, 'followers') + + readthrough = models.ReadThrough.objects.get() + self.assertIsNotNone(readthrough.start_date) + self.assertIsNotNone(readthrough.finish_date) + self.assertEqual(readthrough.user, self.local_user) + self.assertEqual(readthrough.book, self.book) + + + def test_edit_readthrough(self): + ''' adding dates to an ongoing readthrough ''' + start = timezone.make_aware(dateutil.parser.parse('2021-01-03')) + readthrough = models.ReadThrough.objects.create( + book=self.book, user=self.local_user, start_date=start) + request = self.factory.post( + '', { + 'start_date': '2017-01-01', + 'finish_date': '2018-03-07', + 'book': '', + 'id': readthrough.id, + }) + request.user = self.local_user + + views.edit_readthrough(request) + readthrough.refresh_from_db() + self.assertEqual(readthrough.start_date.year, 2017) + self.assertEqual(readthrough.start_date.month, 1) + self.assertEqual(readthrough.start_date.day, 1) + self.assertEqual(readthrough.finish_date.year, 2018) + self.assertEqual(readthrough.finish_date.month, 3) + self.assertEqual(readthrough.finish_date.day, 7) + self.assertEqual(readthrough.book, self.book) + + + def test_delete_readthrough(self): + ''' remove a readthrough ''' + readthrough = models.ReadThrough.objects.create( + book=self.book, user=self.local_user) + models.ReadThrough.objects.create( + book=self.book, user=self.local_user) + request = self.factory.post( + '', { + 'id': readthrough.id, + }) + request.user = self.local_user + + views.delete_readthrough(request) + self.assertFalse( + models.ReadThrough.objects.filter(id=readthrough.id).exists()) + + + def test_create_readthrough(self): + ''' adding new read dates ''' + request = self.factory.post( + '', { + 'start_date': '2017-01-01', + 'finish_date': '2018-03-07', + 'book': self.book.id, + 'id': '', + }) + request.user = self.local_user + + views.create_readthrough(request) + readthrough = models.ReadThrough.objects.get() + self.assertEqual(readthrough.start_date.year, 2017) + self.assertEqual(readthrough.start_date.month, 1) + self.assertEqual(readthrough.start_date.day, 1) + self.assertEqual(readthrough.finish_date.year, 2018) + self.assertEqual(readthrough.finish_date.month, 3) + self.assertEqual(readthrough.finish_date.day, 7) + self.assertEqual(readthrough.book, self.book) + self.assertEqual(readthrough.user, self.local_user) diff --git a/bookwyrm/tests/views/test_search.py b/bookwyrm/tests/views/test_search.py new file mode 100644 index 000000000..3f1d78503 --- /dev/null +++ b/bookwyrm/tests/views/test_search.py @@ -0,0 +1,108 @@ +''' test for app action functionality ''' +import json +from unittest.mock import patch + +from django.http import JsonResponse +from django.template.response import TemplateResponse +from django.test import TestCase +from django.test.client import RequestFactory + +from bookwyrm import models, views +from bookwyrm.connectors import abstract_connector +from bookwyrm.settings import DOMAIN + + +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='Test Book', + remote_id='https://example.com/book/1', + parent_work=self.work + ) + models.Connector.objects.create( + identifier='self', + connector_file='self_connector', + local=True + ) + + + def test_search_json_response(self): + ''' searches local data only and returns book data in json format ''' + view = views.Search.as_view() + # we need a connector for this, sorry + request = self.factory.get('', {'q': 'Test Book'}) + with patch('bookwyrm.views.search.is_api_request') as is_api: + is_api.return_value = True + response = view(request) + self.assertIsInstance(response, JsonResponse) + + data = json.loads(response.content) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['title'], 'Test Book') + self.assertEqual( + data[0]['key'], 'https://%s/book/%d' % (DOMAIN, self.book.id)) + + + def test_search_html_response(self): + ''' searches remote connectors ''' + view = views.Search.as_view() + class TestConnector(abstract_connector.AbstractMinimalConnector): + ''' nothing added here ''' + def format_search_result(self, search_result): + pass + def get_or_create_book(self, remote_id): + pass + def parse_search_data(self, data): + pass + models.Connector.objects.create( + identifier='example.com', + connector_file='openlibrary', + base_url='https://example.com', + books_url='https://example.com/books', + covers_url='https://example.com/covers', + search_url='https://example.com/search?q=', + ) + connector = TestConnector('example.com') + + search_result = abstract_connector.SearchResult( + key='http://www.example.com/book/1', + title='Gideon the Ninth', + author='Tamsyn Muir', + year='2019', + connector=connector + ) + + request = self.factory.get('', {'q': 'Test Book'}) + with patch('bookwyrm.views.search.is_api_request') as is_api: + is_api.return_value = False + with patch( + 'bookwyrm.connectors.connector_manager.search') as manager: + manager.return_value = [search_result] + response = view(request) + self.assertIsInstance(response, TemplateResponse) + self.assertEqual(response.template_name, 'search_results.html') + self.assertEqual( + response.context_data['book_results'][0].title, 'Gideon the Ninth') + + + def test_search_html_response_users(self): + ''' searches remote connectors ''' + view = views.Search.as_view() + request = self.factory.get('', {'q': 'mouse'}) + with patch('bookwyrm.views.search.is_api_request') as is_api: + is_api.return_value = False + with patch('bookwyrm.connectors.connector_manager.search'): + response = view(request) + self.assertIsInstance(response, TemplateResponse) + self.assertEqual(response.template_name, 'search_results.html') + self.assertEqual( + response.context_data['user_results'][0], self.local_user) diff --git a/bookwyrm/tests/views/test_shelf.py b/bookwyrm/tests/views/test_shelf.py new file mode 100644 index 000000000..64966a778 --- /dev/null +++ b/bookwyrm/tests/views/test_shelf.py @@ -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) diff --git a/bookwyrm/tests/views/test_status.py b/bookwyrm/tests/views/test_status.py new file mode 100644 index 000000000..6e0c73df9 --- /dev/null +++ b/bookwyrm/tests/views/test_status.py @@ -0,0 +1,273 @@ +''' 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 forms, models, views +from bookwyrm.activitypub import ActivitypubResponse +from bookwyrm.settings import DOMAIN + + +class StatusViews(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', + ) + with patch('bookwyrm.models.user.set_remote_server'): + self.remote_user = models.User.objects.create_user( + 'rat', 'rat@email.com', 'ratword', + local=False, + remote_id='https://example.com/users/rat', + inbox='https://example.com/users/rat/inbox', + outbox='https://example.com/users/rat/outbox', + ) + + 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=work + ) + + + def test_status_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Status.as_view() + status = models.Status.objects.create( + content='hi', user=self.local_user) + request = self.factory.get('') + request.user = self.local_user + with patch('bookwyrm.views.status.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'mouse', status.id) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'status.html') + self.assertEqual(result.status_code, 200) + + with patch('bookwyrm.views.status.is_api_request') as is_api: + is_api.return_value = True + result = view(request, 'mouse', status.id) + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + + def test_replies_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Replies.as_view() + status = models.Status.objects.create( + content='hi', user=self.local_user) + request = self.factory.get('') + request.user = self.local_user + with patch('bookwyrm.views.status.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'mouse', status.id) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'status.html') + self.assertEqual(result.status_code, 200) + + with patch('bookwyrm.views.status.is_api_request') as is_api: + is_api.return_value = True + result = view(request, 'mouse', status.id) + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + + def test_handle_status(self): + ''' create a status ''' + view = views.CreateStatus.as_view() + form = forms.CommentForm({ + 'content': 'hi', + 'user': self.local_user.id, + 'book': self.book.id, + 'privacy': 'public', + }) + request = self.factory.post('', form.data) + request.user = self.local_user + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, 'comment') + status = models.Comment.objects.get() + self.assertEqual(status.content, '

hi

') + self.assertEqual(status.user, self.local_user) + self.assertEqual(status.book, self.book) + + def test_handle_status_reply(self): + ''' create a status in reply to an existing status ''' + view = views.CreateStatus.as_view() + user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'password', local=True) + parent = models.Status.objects.create( + content='parent status', user=self.local_user) + form = forms.ReplyForm({ + 'content': 'hi', + 'user': user.id, + 'reply_parent': parent.id, + 'privacy': 'public', + }) + request = self.factory.post('', form.data) + request.user = user + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, 'reply') + status = models.Status.objects.get(user=user) + self.assertEqual(status.content, '

hi

') + self.assertEqual(status.user, user) + self.assertEqual( + models.Notification.objects.get().user, self.local_user) + + def test_handle_status_mentions(self): + ''' @mention a user in a post ''' + view = views.CreateStatus.as_view() + user = models.User.objects.create_user( + 'rat@%s' % DOMAIN, 'rat@rat.com', 'password', + local=True, localname='rat') + form = forms.CommentForm({ + 'content': 'hi @rat', + 'user': self.local_user.id, + 'book': self.book.id, + 'privacy': 'public', + }) + request = self.factory.post('', form.data) + request.user = self.local_user + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, 'comment') + status = models.Status.objects.get() + self.assertEqual(list(status.mention_users.all()), [user]) + self.assertEqual(models.Notification.objects.get().user, user) + self.assertEqual( + status.content, + '

hi @rat

' % user.remote_id) + + def test_handle_status_reply_with_mentions(self): + ''' reply to a post with an @mention'ed user ''' + view = views.CreateStatus.as_view() + user = models.User.objects.create_user( + 'rat', 'rat@rat.com', 'password', + local=True, localname='rat') + form = forms.CommentForm({ + 'content': 'hi @rat@example.com', + 'user': self.local_user.id, + 'book': self.book.id, + 'privacy': 'public', + }) + request = self.factory.post('', form.data) + request.user = self.local_user + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, 'comment') + status = models.Status.objects.get() + + form = forms.ReplyForm({ + 'content': 'right', + 'user': user.id, + 'privacy': 'public', + 'reply_parent': status.id + }) + request = self.factory.post('', form.data) + request.user = user + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, 'reply') + + reply = models.Status.replies(status).first() + self.assertEqual(reply.content, '

right

') + self.assertEqual(reply.user, user) + self.assertTrue(self.remote_user in reply.mention_users.all()) + self.assertTrue(self.local_user in reply.mention_users.all()) + + def test_find_mentions(self): + ''' detect and look up @ mentions of users ''' + user = models.User.objects.create_user( + 'nutria@%s' % DOMAIN, 'nutria@nutria.com', 'password', + local=True, localname='nutria') + self.assertEqual(user.username, 'nutria@%s' % DOMAIN) + + self.assertEqual( + list(views.status.find_mentions('@nutria'))[0], + ('@nutria', user) + ) + self.assertEqual( + list(views.status.find_mentions('leading text @nutria'))[0], + ('@nutria', user) + ) + self.assertEqual( + list(views.status.find_mentions( + 'leading @nutria trailing text'))[0], + ('@nutria', user) + ) + self.assertEqual( + list(views.status.find_mentions( + '@rat@example.com'))[0], + ('@rat@example.com', self.remote_user) + ) + + multiple = list(views.status.find_mentions( + '@nutria and @rat@example.com')) + self.assertEqual(multiple[0], ('@nutria', user)) + self.assertEqual(multiple[1], ('@rat@example.com', self.remote_user)) + + with patch('bookwyrm.views.status.handle_remote_webfinger') as rw: + rw.return_value = self.local_user + self.assertEqual( + list(views.status.find_mentions('@beep@beep.com'))[0], + ('@beep@beep.com', self.local_user) + ) + with patch('bookwyrm.views.status.handle_remote_webfinger') as rw: + rw.return_value = None + self.assertEqual(list(views.status.find_mentions( + '@beep@beep.com')), []) + + self.assertEqual( + list(views.status.find_mentions('@nutria@%s' % DOMAIN))[0], + ('@nutria@%s' % DOMAIN, user) + ) + + def test_format_links(self): + ''' find and format urls into a tags ''' + url = 'http://www.fish.com/' + self.assertEqual( + views.status.format_links(url), + 'www.fish.com/' % url) + self.assertEqual( + views.status.format_links('(%s)' % url), + '(www.fish.com/)' % url) + url = 'https://archive.org/details/dli.granth.72113/page/n25/mode/2up' + self.assertEqual( + views.status.format_links(url), + '' \ + 'archive.org/details/dli.granth.72113/page/n25/mode/2up' \ + % url) + url = 'https://openlibrary.org/search' \ + '?q=arkady+strugatsky&mode=everything' + self.assertEqual( + views.status.format_links(url), + 'openlibrary.org/search' \ + '?q=arkady+strugatsky&mode=everything' % url) + + + def test_to_markdown(self): + ''' this is mostly handled in other places, but nonetheless ''' + text = '_hi_ and http://fish.com is rad' + result = views.status.to_markdown(text) + self.assertEqual( + result, + '

hi and fish.com ' \ + 'is rad

') + + + def test_handle_delete_status(self): + ''' marks a status as deleted ''' + view = views.DeleteStatus.as_view() + status = models.Status.objects.create( + user=self.local_user, content='hi') + self.assertFalse(status.deleted) + request = self.factory.post('') + request.user = self.local_user + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request, status.id) + status.refresh_from_db() + self.assertTrue(status.deleted) diff --git a/bookwyrm/tests/views/test_tag.py b/bookwyrm/tests/views/test_tag.py new file mode 100644 index 000000000..1556139ca --- /dev/null +++ b/bookwyrm/tests/views/test_tag.py @@ -0,0 +1,99 @@ +''' 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 models, views +from bookwyrm.activitypub import ActivitypubResponse + + +class TagViews(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.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_tag_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Tag.as_view() + tag = models.Tag.objects.create(name='hi there') + models.UserTag.objects.create( + tag=tag, user=self.local_user, book=self.book) + request = self.factory.get('') + with patch('bookwyrm.views.tag.is_api_request') as is_api: + is_api.return_value = False + result = view(request, tag.identifier) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'tag.html') + self.assertEqual(result.status_code, 200) + + request = self.factory.get('') + with patch('bookwyrm.views.tag.is_api_request') as is_api: + is_api.return_value = True + result = view(request, tag.identifier) + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + + def test_tag(self): + ''' add a tag to a book ''' + view = views.AddTag.as_view() + request = self.factory.post( + '', { + 'name': 'A Tag!?', + 'book': self.book.id, + }) + request.user = self.local_user + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request) + + tag = models.Tag.objects.get() + user_tag = models.UserTag.objects.get() + self.assertEqual(tag.name, 'A Tag!?') + self.assertEqual(tag.identifier, 'A+Tag%21%3F') + self.assertEqual(user_tag.user, self.local_user) + self.assertEqual(user_tag.book, self.book) + + + def test_untag(self): + ''' remove a tag from a book ''' + view = views.RemoveTag.as_view() + tag = models.Tag.objects.create(name='A Tag!?') + models.UserTag.objects.create( + user=self.local_user, book=self.book, tag=tag) + request = self.factory.post( + '', { + 'user': self.local_user.id, + 'book': self.book.id, + 'name': tag.name, + }) + request.user = self.local_user + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request) + + self.assertTrue(models.Tag.objects.filter(name='A Tag!?').exists()) + self.assertFalse(models.UserTag.objects.exists()) diff --git a/bookwyrm/tests/views/test_user.py b/bookwyrm/tests/views/test_user.py new file mode 100644 index 000000000..0e2ad9044 --- /dev/null +++ b/bookwyrm/tests/views/test_user.py @@ -0,0 +1,99 @@ +''' 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 forms, models, views +from bookwyrm.activitypub import ActivitypubResponse + + +class UserViews(TestCase): + ''' view user and edit profile ''' + 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.mouse', 'password', + local=True, localname='mouse') + + + def test_user_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.User.as_view() + request = self.factory.get('') + request.user = self.local_user + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'mouse') + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'user.html') + self.assertEqual(result.status_code, 200) + + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = True + result = view(request, 'mouse') + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + + def test_followers_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Followers.as_view() + request = self.factory.get('') + request.user = self.local_user + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'mouse') + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'followers.html') + self.assertEqual(result.status_code, 200) + + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = True + result = view(request, 'mouse') + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + + def test_following_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.Following.as_view() + request = self.factory.get('') + request.user = self.local_user + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = False + result = view(request, 'mouse') + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'following.html') + self.assertEqual(result.status_code, 200) + + with patch('bookwyrm.views.user.is_api_request') as is_api: + is_api.return_value = True + result = view(request, 'mouse') + self.assertIsInstance(result, ActivitypubResponse) + self.assertEqual(result.status_code, 200) + + + def test_edit_profile_page(self): + ''' there are so many views, this just makes sure it LOADS ''' + view = views.EditUser.as_view() + request = self.factory.get('') + request.user = self.local_user + result = view(request) + self.assertIsInstance(result, TemplateResponse) + self.assertEqual(result.template_name, 'edit_user.html') + self.assertEqual(result.status_code, 200) + + + def test_edit_user(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' + request = self.factory.post('', form.data) + request.user = self.local_user + + with patch('bookwyrm.broadcast.broadcast_task.delay'): + view(request) + self.assertEqual(self.local_user.name, 'New Name') diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 3abcac110..4ed162884 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -3,8 +3,7 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import path, re_path -from bookwyrm import incoming, outgoing, views, settings, wellknown -from bookwyrm import view_actions as actions +from bookwyrm import incoming, settings, views, wellknown from bookwyrm.utils import regex user_path = r'^user/(?P%s)' % regex.username @@ -31,7 +30,7 @@ urlpatterns = [ # federation endpoints re_path(r'^inbox/?$', incoming.shared_inbox), re_path(r'%s/inbox/?$' % local_user_path, incoming.inbox), - re_path(r'%s/outbox/?$' % local_user_path, outgoing.outbox), + re_path(r'%s/outbox/?$' % local_user_path, views.Outbox.as_view()), # .well-known endpoints re_path(r'^.well-known/webfinger/?$', wellknown.webfinger), @@ -39,109 +38,97 @@ urlpatterns = [ re_path(r'^nodeinfo/2\.0/?$', wellknown.nodeinfo), re_path(r'^api/v1/instance/?$', wellknown.instance_info), re_path(r'^api/v1/instance/peers/?$', wellknown.peers), - # TODO: re_path(r'^.well-known/host-meta/?$', incoming.host_meta), - # TODO: robots.txt - # ui views - re_path(r'^login/?$', views.login_page), - re_path(r'^about/?$', views.about_page), - re_path(r'^password-reset/?$', views.password_reset_request), - re_path(r'^password-reset/(?P[A-Za-z0-9]+)/?$', views.password_reset), - re_path(r'^invite/?$', views.manage_invites), - re_path(r'^invite/(?P[A-Za-z0-9]+)/?$', views.invite_page), + # authentication + re_path(r'^login/?$', views.Login.as_view()), + re_path(r'^register/?$', views.Register.as_view()), + re_path(r'^logout/?$', views.Logout.as_view()), + re_path(r'^password-reset/?$', views.PasswordResetRequest.as_view()), + re_path(r'^password-reset/(?P[A-Za-z0-9]+)/?$', + views.PasswordReset.as_view()), + re_path(r'^change-password/?$', views.ChangePassword), - path('', views.home), - re_path(r'^(?Phome|local|federated)/?$', views.home_tab), - re_path(r'^discover/?$', views.discover_page), - re_path(r'^notifications/?$', views.notifications_page), - re_path(r'^direct-messages/?$', views.direct_messages_page), - re_path(r'^import/?$', views.import_page), - re_path(r'^import-status/(\d+)/?$', views.import_status), - re_path(r'^user-edit/?$', views.edit_profile_page), + # invites + re_path(r'^invite/?$', views.ManageInvites.as_view()), + re_path(r'^invite/(?P[A-Za-z0-9]+)/?$', views.Invite.as_view()), + + # landing pages + re_path(r'^about/?$', views.About.as_view()), + path('', views.Home.as_view()), + re_path(r'^(?Phome|local|federated)/?$', views.Feed.as_view()), + re_path(r'^discover/?$', views.Discover.as_view()), + re_path(r'^notifications/?$', views.Notifications.as_view()), + re_path(r'^direct-messages/?$', views.DirectMessage.as_view()), + + # search + re_path(r'^search/?$', views.Search.as_view()), + + # imports + re_path(r'^import/?$', views.Import.as_view()), + re_path(r'^import/(\d+)/?$', views.ImportStatus.as_view()), - # should return a ui view or activitypub json blob as requested # users - re_path(r'%s/?$' % user_path, views.user_page), - re_path(r'%s\.json$' % local_user_path, views.user_page), - re_path(r'%s/?$' % local_user_path, views.user_page), - re_path(r'%s/shelves/?$' % local_user_path, views.user_shelves_page), - re_path(r'%s/followers(.json)?/?$' % local_user_path, views.followers_page), - re_path(r'%s/following(.json)?/?$' % local_user_path, views.following_page), + 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/shelves/?$' % user_path, views.user_shelves_page), + 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'^edit-profile/?$', views.EditUser.as_view()), # statuses - re_path(r'%s(.json)?/?$' % status_path, views.status_page), - re_path(r'%s/activity/?$' % status_path, views.status_page), - re_path(r'%s/replies(.json)?/?$' % status_path, views.replies_page), + re_path(r'%s(.json)?/?$' % status_path, views.Status.as_view()), + re_path(r'%s/activity/?$' % status_path, views.Status.as_view()), + re_path(r'%s/replies(.json)?/?$' % status_path, views.Replies.as_view()), + re_path(r'^post/(?P\w+)/?$', views.CreateStatus.as_view()), + re_path(r'^delete-status/(?P\d+)/?$', + views.DeleteStatus.as_view()), + + # interact + re_path(r'^favorite/(?P\d+)/?$', views.Favorite.as_view()), + re_path(r'^unfavorite/(?P\d+)/?$', views.Unfavorite.as_view()), + re_path(r'^boost/(?P\d+)/?$', views.Boost.as_view()), + re_path(r'^unboost/(?P\d+)/?$', views.Unboost.as_view()), # books - re_path(r'%s(.json)?/?$' % book_path, views.book_page), - re_path(r'%s/edit/?$' % book_path, views.edit_book_page), - re_path(r'^author/(?P[\w\-]+)/edit/?$', views.edit_author_page), - re_path(r'%s/editions(.json)?/?$' % book_path, views.editions_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\d+)/?$', views.upload_cover), + re_path(r'^add-description/(?P\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[\w\-]+)(.json)?/?$', views.author_page), - re_path(r'^tag/(?P.+)\.json/?$', views.tag_page), - re_path(r'^tag/(?P.+)/?$', views.tag_page), + # author + re_path(r'^author/(?P\d+)(.json)?/?$', views.Author.as_view()), + re_path(r'^author/(?P\d+)/edit/?$', views.EditAuthor.as_view()), + + # tags + re_path(r'^tag/(?P.+)\.json/?$', views.Tag.as_view()), + re_path(r'^tag/(?P.+)/?$', views.Tag.as_view()), + re_path(r'^tag/?$', views.AddTag.as_view()), + re_path(r'^untag/?$', views.RemoveTag.as_view()), + + # shelf re_path(r'^%s/shelf/(?P[\w-]+)(.json)?/?$' % \ - user_path, views.shelf_page), + user_path, views.Shelf.as_view()), re_path(r'^%s/shelf/(?P[\w-]+)(.json)?/?$' % \ - local_user_path, views.shelf_page), + local_user_path, views.Shelf.as_view()), + re_path(r'^create-shelf/?$', views.create_shelf), + re_path(r'^delete-shelf/(?P\d+)?$', views.delete_shelf), + re_path(r'^shelve/?$', views.shelve), + re_path(r'^unshelve/?$', views.unshelve), - re_path(r'^search/?$', views.search), + # reading progress + re_path(r'^edit-readthrough/?$', views.edit_readthrough), + re_path(r'^delete-readthrough/?$', views.delete_readthrough), + re_path(r'^create-readthrough/?$', views.create_readthrough), - # internal action endpoints - re_path(r'^logout/?$', actions.user_logout), - re_path(r'^user-login/?$', actions.user_login), - re_path(r'^user-register/?$', actions.register), - re_path(r'^reset-password-request/?$', actions.password_reset_request), - re_path(r'^reset-password/?$', actions.password_reset), - re_path(r'^change-password/?$', actions.password_change), - - re_path(r'^edit-profile/?$', actions.edit_profile), - - re_path(r'^import-data/?$', actions.import_data), - re_path(r'^retry-import/?$', actions.retry_import), - re_path(r'^resolve-book/?$', actions.resolve_book), - re_path(r'^edit-book/(?P\d+)/?$', actions.edit_book), - re_path(r'^upload-cover/(?P\d+)/?$', actions.upload_cover), - re_path(r'^add-description/(?P\d+)/?$', actions.add_description), - re_path(r'^edit-author/(?P\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), - - re_path(r'^rate/?$', actions.rate), - re_path(r'^review/?$', actions.review), - re_path(r'^quote/?$', actions.quotate), - re_path(r'^comment/?$', actions.comment), - re_path(r'^tag/?$', actions.tag), - re_path(r'^untag/?$', actions.untag), - re_path(r'^reply/?$', actions.reply), - - re_path(r'^favorite/(?P\d+)/?$', actions.favorite), - re_path(r'^unfavorite/(?P\d+)/?$', actions.unfavorite), - re_path(r'^boost/(?P\d+)/?$', actions.boost), - re_path(r'^unboost/(?P\d+)/?$', actions.unboost), - - re_path(r'^delete-status/(?P\d+)/?$', actions.delete_status), - - re_path(r'^create-shelf/?$', actions.create_shelf), - re_path(r'^edit-shelf/(?P\d+)?$', actions.edit_shelf), - re_path(r'^delete-shelf/(?P\d+)?$', actions.delete_shelf), - re_path(r'^shelve/?$', actions.shelve), - re_path(r'^unshelve/?$', actions.unshelve), - re_path(r'^start-reading/(?P\d+)/?$', actions.start_reading), - re_path(r'^finish-reading/(?P\d+)/?$', actions.finish_reading), - - re_path(r'^follow/?$', actions.follow), - re_path(r'^unfollow/?$', actions.unfollow), - re_path(r'^accept-follow-request/?$', actions.accept_follow_request), - re_path(r'^delete-follow-request/?$', actions.delete_follow_request), - - re_path(r'^clear-notifications/?$', actions.clear_notifications), - - re_path(r'^create-invite/?$', actions.create_invite), + re_path(r'^start-reading/(?P\d+)/?$', views.start_reading), + re_path(r'^finish-reading/(?P\d+)/?$', views.finish_reading), + # following + re_path(r'^follow/?$', views.follow), + re_path(r'^unfollow/?$', views.unfollow), + re_path(r'^accept-follow-request/?$', views.accept_follow_request), + re_path(r'^delete-follow-request/?$', views.delete_follow_request), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py deleted file mode 100644 index eff70ff63..000000000 --- a/bookwyrm/view_actions.py +++ /dev/null @@ -1,857 +0,0 @@ -''' views for actions you can take in the application ''' -from io import BytesIO, TextIOWrapper -from uuid import uuid4 -from PIL import Image - -import dateutil.parser -from dateutil.parser import ParserError - -from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.decorators import login_required, permission_required -from django.core.exceptions import PermissionDenied -from django.core.files.base import ContentFile -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 -from django.utils import timezone -from django.views.decorators.http import require_GET, require_POST - -from bookwyrm import forms, models, outgoing, goodreads_import -from bookwyrm.connectors import connector_manager -from bookwyrm.broadcast import broadcast -from bookwyrm.emailing import password_reset_email -from bookwyrm.settings import DOMAIN -from bookwyrm.views import get_user_from_username, get_edition - - -@require_POST -def user_login(request): - ''' authenticate user login ''' - login_form = forms.LoginForm(request.POST) - - localname = login_form.data['localname'] - username = '%s@%s' % (localname, DOMAIN) - password = login_form.data['password'] - user = authenticate(request, username=username, password=password) - if user is not None: - # successful login - login(request, user) - user.last_active_date = timezone.now() - return redirect(request.GET.get('next', '/')) - - login_form.non_field_errors = 'Username or password are incorrect' - register_form = forms.RegisterForm() - data = { - 'login_form': login_form, - 'register_form': register_form - } - return TemplateResponse(request, 'login.html', data) - - -@require_POST -def register(request): - ''' join the server ''' - if not models.SiteSettings.get().allow_registration: - invite_code = request.POST.get('invite_code') - - if not invite_code: - raise PermissionDenied - - invite = get_object_or_404(models.SiteInvite, code=invite_code) - if not invite.valid(): - raise PermissionDenied - else: - invite = None - - form = forms.RegisterForm(request.POST) - errors = False - if not form.is_valid(): - errors = True - - localname = form.data['localname'].strip() - email = form.data['email'] - password = form.data['password'] - - # check localname and email uniqueness - if models.User.objects.filter(localname=localname).first(): - form.errors['localname'] = ['User with this username already exists'] - errors = True - - if errors: - data = { - 'login_form': forms.LoginForm(), - 'register_form': form, - 'invite': invite, - 'valid': invite.valid() if invite else True, - } - if invite: - return TemplateResponse(request, 'invite.html', data) - return TemplateResponse(request, 'login.html', data) - - username = '%s@%s' % (localname, DOMAIN) - user = models.User.objects.create_user( - username, email, password, localname=localname, local=True) - if invite: - invite.times_used += 1 - invite.save() - - login(request, user) - return redirect('/') - - -@login_required -@require_GET -def user_logout(request): - ''' done with this place! outa here! ''' - logout(request) - return redirect('/') - - -@require_POST -def password_reset_request(request): - ''' create a password reset token ''' - email = request.POST.get('email') - try: - user = models.User.objects.get(email=email) - except models.User.DoesNotExist: - return redirect('/password-reset') - - # remove any existing password reset cods for this user - models.PasswordReset.objects.filter(user=user).all().delete() - - # create a new reset code - code = models.PasswordReset.objects.create(user=user) - password_reset_email(code) - data = {'message': 'Password reset link sent to %s' % email} - return TemplateResponse(request, 'password_reset_request.html', data) - - -@require_POST -def password_reset(request): - ''' allow a user to change their password through an emailed token ''' - try: - reset_code = models.PasswordReset.objects.get( - code=request.POST.get('reset-code') - ) - except models.PasswordReset.DoesNotExist: - data = {'errors': ['Invalid password reset link']} - return TemplateResponse(request, 'password_reset.html', data) - - user = reset_code.user - - new_password = request.POST.get('password') - confirm_password = request.POST.get('confirm-password') - - if new_password != confirm_password: - data = {'errors': ['Passwords do not match']} - return TemplateResponse(request, 'password_reset.html', data) - - user.set_password(new_password) - user.save() - login(request, user) - reset_code.delete() - return redirect('/') - - -@login_required -@require_POST -def password_change(request): - ''' allow a user to change their password ''' - new_password = request.POST.get('password') - confirm_password = request.POST.get('confirm-password') - - if new_password != confirm_password: - return redirect('/user-edit') - - request.user.set_password(new_password) - request.user.save() - login(request, request.user) - return redirect('/user/%s' % request.user.localname) - - -@login_required -@require_POST -def edit_profile(request): - ''' les get fancy with images ''' - form = forms.EditUserForm( - request.POST, request.FILES, instance=request.user) - if not form.is_valid(): - data = {'form': form, 'user': request.user} - return TemplateResponse(request, 'edit_user.html', data) - - user = form.save(commit=False) - - if 'avatar' in form.files: - # crop and resize avatar upload - image = Image.open(form.files['avatar']) - target_size = 120 - width, height = image.size - thumbnail_scale = height / (width / target_size) if height > width \ - else width / (height / target_size) - image.thumbnail([thumbnail_scale, thumbnail_scale]) - width, height = image.size - - width_diff = width - target_size - height_diff = height - target_size - cropped = image.crop(( - int(width_diff / 2), - int(height_diff / 2), - int(width - (width_diff / 2)), - int(height - (height_diff / 2)) - )) - output = BytesIO() - cropped.save(output, format=image.format) - ContentFile(output.getvalue()) - - # set the name to a hash - extension = form.files['avatar'].name.split('.')[-1] - filename = '%s.%s' % (uuid4(), extension) - user.avatar.save(filename, ContentFile(output.getvalue())) - user.save() - - broadcast(user, user.to_update_activity(user)) - return redirect('/user/%s' % request.user.localname) - - -@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 -def edit_author(request, author_id): - ''' edit a author cool ''' - author = get_object_or_404(models.Author, id=author_id) - - form = forms.AuthorForm(request.POST, request.FILES, instance=author) - if not form.is_valid(): - data = { - 'title': 'Edit Author', - 'author': author, - 'form': form - } - return TemplateResponse(request, 'edit_author.html', data) - author = form.save() - - broadcast(request.user, author.to_update_activity(request.user)) - return redirect('/author/%s' % author.id) - - -@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 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: - current_shelf = models.Shelf.objects.get( - user=request.user, - edition=book - ) - 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 -@require_POST -def start_reading(request, book_id): - ''' begin reading a book ''' - book = get_edition(book_id) - shelf = models.Shelf.objects.filter( - identifier='reading', - user=request.user - ).first() - - # create a readthrough - readthrough = update_readthrough(request, book=book) - if readthrough.start_date: - readthrough.save() - - # shelve the book - if request.POST.get('reshelve', True): - try: - current_shelf = models.Shelf.objects.get( - user=request.user, - edition=book - ) - 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, shelf) - - # post about it (if you want) - if request.POST.get('post-status'): - privacy = request.POST.get('privacy') - outgoing.handle_reading_status(request.user, shelf, book, privacy) - - return redirect(request.headers.get('Referer', '/')) - - -@login_required -@require_POST -def finish_reading(request, book_id): - ''' a user completed a book, yay ''' - book = get_edition(book_id) - shelf = models.Shelf.objects.filter( - identifier='read', - user=request.user - ).first() - - # update or create a readthrough - readthrough = update_readthrough(request, book=book) - if readthrough.start_date or readthrough.finish_date: - readthrough.save() - - # shelve the book - if request.POST.get('reshelve', True): - try: - current_shelf = models.Shelf.objects.get( - user=request.user, - edition=book - ) - 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, shelf) - - # post about it (if you want) - if request.POST.get('post-status'): - privacy = request.POST.get('privacy') - outgoing.handle_reading_status(request.user, shelf, book, privacy) - - return redirect(request.headers.get('Referer', '/')) - - -@login_required -@require_POST -def edit_readthrough(request): - ''' can't use the form because the dates are too finnicky ''' - readthrough = update_readthrough(request, create=False) - if not readthrough: - return HttpResponseNotFound() - - # don't let people edit other people's data - if request.user != readthrough.user: - return HttpResponseBadRequest() - readthrough.save() - - return redirect(request.headers.get('Referer', '/')) - - -@login_required -@require_POST -def delete_readthrough(request): - ''' remove a readthrough ''' - readthrough = get_object_or_404( - models.ReadThrough, id=request.POST.get('id')) - - # don't let people edit other people's data - if request.user != readthrough.user: - return HttpResponseBadRequest() - - readthrough.delete() - return redirect(request.headers.get('Referer', '/')) - - -@login_required -@require_POST -def create_readthrough(request): - ''' can't use the form because the dates are too finnicky ''' - book = get_object_or_404(models.Edition, id=request.POST.get('book')) - readthrough = update_readthrough(request, create=True, book=book) - if not readthrough: - return redirect(book.local_path) - readthrough.save() - return redirect(request.headers.get('Referer', '/')) - - -@login_required -@require_POST -def rate(request): - ''' just a star rating for a book ''' - form = forms.RatingForm(request.POST) - return handle_status(request, form) - - -@login_required -@require_POST -def review(request): - ''' create a book review ''' - form = forms.ReviewForm(request.POST) - return handle_status(request, form) - - -@login_required -@require_POST -def quotate(request): - ''' create a book quotation ''' - form = forms.QuotationForm(request.POST) - return handle_status(request, form) - - -@login_required -@require_POST -def comment(request): - ''' create a book comment ''' - form = forms.CommentForm(request.POST) - return handle_status(request, form) - - -@login_required -@require_POST -def reply(request): - ''' respond to a book review ''' - form = forms.ReplyForm(request.POST) - return handle_status(request, form) - - -def handle_status(request, form): - ''' all the "create a status" functions are the same ''' - if not form.is_valid(): - return redirect(request.headers.get('Referer', '/')) - - outgoing.handle_status(request.user, form) - return redirect(request.headers.get('Referer', '/')) - - -@login_required -@require_POST -def tag(request): - ''' tag a book ''' - # I'm not using a form here because sometimes "name" is sent as a hidden - # field which doesn't validate - name = request.POST.get('name') - book_id = request.POST.get('book') - book = get_object_or_404(models.Edition, id=book_id) - tag_obj, created = models.Tag.objects.get_or_create( - name=name, - ) - user_tag, _ = models.UserTag.objects.get_or_create( - user=request.user, - book=book, - tag=tag_obj, - ) - - if created: - broadcast(request.user, user_tag.to_add_activity(request.user)) - return redirect('/book/%s' % book_id) - - -@login_required -@require_POST -def untag(request): - ''' untag a book ''' - name = request.POST.get('name') - tag_obj = get_object_or_404(models.Tag, name=name) - book_id = request.POST.get('book') - book = get_object_or_404(models.Edition, id=book_id) - - user_tag = get_object_or_404( - models.UserTag, tag=tag_obj, book=book, user=request.user) - tag_activity = user_tag.to_remove_activity(request.user) - user_tag.delete() - - broadcast(request.user, tag_activity) - return redirect('/book/%s' % book_id) - - -@login_required -@require_POST -def favorite(request, status_id): - ''' like a status ''' - status = models.Status.objects.get(id=status_id) - outgoing.handle_favorite(request.user, status) - return redirect(request.headers.get('Referer', '/')) - - -@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 delete_status(request, status_id): - ''' delete and tombstone a status ''' - status = get_object_or_404(models.Status, id=status_id) - - # don't let people delete other people's statuses - if status.user != request.user: - return HttpResponseBadRequest() - - # perform deletion - outgoing.handle_delete_status(request.user, status) - return redirect(request.headers.get('Referer', '/')) - - -@login_required -@require_POST -def follow(request): - ''' follow another user, here or abroad ''' - username = request.POST['user'] - try: - to_follow = get_user_from_username(username) - except models.User.DoesNotExist: - return HttpResponseBadRequest() - - outgoing.handle_follow(request.user, to_follow) - user_slug = to_follow.localname if to_follow.localname \ - else to_follow.username - return redirect('/user/%s' % user_slug) - - -@login_required -@require_POST -def unfollow(request): - ''' unfollow a user ''' - username = request.POST['user'] - try: - to_unfollow = get_user_from_username(username) - except models.User.DoesNotExist: - return HttpResponseBadRequest() - - outgoing.handle_unfollow(request.user, to_unfollow) - user_slug = to_unfollow.localname if to_unfollow.localname \ - else to_unfollow.username - return redirect('/user/%s' % user_slug) - - -@login_required -def clear_notifications(request): - ''' permanently delete notification for user ''' - request.user.notification_set.filter(read=True).delete() - return redirect('/notifications') - - -@login_required -@require_POST -def accept_follow_request(request): - ''' a user accepts a follow request ''' - username = request.POST['user'] - try: - requester = get_user_from_username(username) - except models.User.DoesNotExist: - return HttpResponseBadRequest() - - try: - follow_request = models.UserFollowRequest.objects.get( - user_subject=requester, - user_object=request.user - ) - except models.UserFollowRequest.DoesNotExist: - # Request already dealt with. - pass - else: - outgoing.handle_accept(follow_request) - - return redirect('/user/%s' % request.user.localname) - - -@login_required -@require_POST -def delete_follow_request(request): - ''' a user rejects a follow request ''' - username = request.POST['user'] - try: - requester = get_user_from_username(username) - except models.User.DoesNotExist: - return HttpResponseBadRequest() - - try: - follow_request = models.UserFollowRequest.objects.get( - user_subject=requester, - user_object=request.user - ) - except models.UserFollowRequest.DoesNotExist: - return HttpResponseBadRequest() - - outgoing.handle_reject(follow_request) - return redirect('/user/%s' % request.user.localname) - - -@login_required -@require_POST -def import_data(request): - ''' ingest a goodreads csv ''' - form = forms.ImportForm(request.POST, request.FILES) - if form.is_valid(): - include_reviews = request.POST.get('include_reviews') == 'on' - privacy = request.POST.get('privacy') - try: - job = goodreads_import.create_job( - request.user, - TextIOWrapper( - request.FILES['csv_file'], - encoding=request.encoding), - include_reviews, - privacy, - ) - except (UnicodeDecodeError, ValueError): - return HttpResponseBadRequest('Not a valid csv file') - goodreads_import.start_import(job) - return redirect('/import-status/%d' % job.id) - return HttpResponseBadRequest() - - -@login_required -@require_POST -def retry_import(request): - ''' ingest a goodreads csv ''' - job = get_object_or_404(models.ImportJob, id=request.POST.get('import_job')) - items = [] - for item in request.POST.getlist('import_item'): - items.append(get_object_or_404(models.ImportItem, id=item)) - - job = goodreads_import.create_retry_job( - request.user, - job, - items, - ) - goodreads_import.start_import(job) - return redirect('/import-status/%d' % job.id) - - -@login_required -@require_POST -@permission_required('bookwyrm.create_invites', raise_exception=True) -def create_invite(request): - ''' creates a user invite database entry ''' - form = forms.CreateInviteForm(request.POST) - if not form.is_valid(): - return HttpResponseBadRequest("ERRORS : %s" % (form.errors,)) - - invite = form.save(commit=False) - invite.user = request.user - invite.save() - - return redirect('/invite') - - -def update_readthrough(request, book=None, create=True): - ''' updates but does not save dates on a readthrough ''' - try: - read_id = request.POST.get('id') - if not read_id: - raise models.ReadThrough.DoesNotExist - readthrough = models.ReadThrough.objects.get(id=read_id) - except models.ReadThrough.DoesNotExist: - if not create or not book: - return None - readthrough = models.ReadThrough( - user=request.user, - book=book, - ) - - start_date = request.POST.get('start_date') - if start_date: - try: - start_date = timezone.make_aware(dateutil.parser.parse(start_date)) - readthrough.start_date = start_date - except ParserError: - pass - - finish_date = request.POST.get('finish_date') - if finish_date: - try: - finish_date = timezone.make_aware( - dateutil.parser.parse(finish_date)) - readthrough.finish_date = finish_date - except ParserError: - pass - - if not readthrough.start_date and not readthrough.finish_date: - return None - - return readthrough diff --git a/bookwyrm/views.py b/bookwyrm/views.py deleted file mode 100644 index faacd2ff8..000000000 --- a/bookwyrm/views.py +++ /dev/null @@ -1,837 +0,0 @@ -''' 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') - - 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 -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) diff --git a/bookwyrm/views/__init__.py b/bookwyrm/views/__init__.py new file mode 100644 index 000000000..6351a87a6 --- /dev/null +++ b/bookwyrm/views/__init__.py @@ -0,0 +1,25 @@ +''' make sure all our nice views are available ''' +from .authentication import Login, Register, Logout +from .author import Author, EditAuthor +from .books import Book, EditBook, Editions +from .books import upload_cover, add_description, switch_edition, resolve_book +from .direct_message import DirectMessage +from .error import not_found_page, server_error_page +from .follow import follow, unfollow +from .follow import accept_follow_request, delete_follow_request, handle_accept +from .import_data import Import, ImportStatus +from .interaction import Favorite, Unfavorite, Boost, Unboost +from .invite import ManageInvites, Invite +from .landing import About, Home, Feed, Discover +from .notifications import Notifications +from .outbox import Outbox +from .reading import edit_readthrough, create_readthrough, delete_readthrough +from .reading import start_reading, finish_reading +from .password import PasswordResetRequest, PasswordReset, ChangePassword +from .tag import Tag, AddTag, RemoveTag +from .search import Search +from .shelf import Shelf +from .shelf import user_shelves_page, create_shelf, delete_shelf +from .shelf import shelve, unshelve +from .status import Status, Replies, CreateStatus, DeleteStatus +from .user import User, EditUser, Followers, Following diff --git a/bookwyrm/views/authentication.py b/bookwyrm/views/authentication.py new file mode 100644 index 000000000..30632bf87 --- /dev/null +++ b/bookwyrm/views/authentication.py @@ -0,0 +1,113 @@ +''' class views for login/register views ''' +from django.contrib.auth import authenticate, login, logout +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views import View + +from bookwyrm import forms, models +from bookwyrm.settings import DOMAIN + + +# pylint: disable= no-self-use +class Login(View): + ''' authenticate an existing user ''' + def get(self, request): + ''' login page ''' + if request.user.is_authenticated: + return redirect('/') + # sene user to the login page + data = { + 'title': 'Login', + 'login_form': forms.LoginForm(), + 'register_form': forms.RegisterForm(), + } + return TemplateResponse(request, 'login.html', data) + + def post(self, request): + ''' authentication action ''' + login_form = forms.LoginForm(request.POST) + + localname = login_form.data['localname'] + username = '%s@%s' % (localname, DOMAIN) + password = login_form.data['password'] + user = authenticate(request, username=username, password=password) + if user is not None: + # successful login + login(request, user) + user.last_active_date = timezone.now() + return redirect(request.GET.get('next', '/')) + + # login errors + login_form.non_field_errors = 'Username or password are incorrect' + register_form = forms.RegisterForm() + data = { + 'login_form': login_form, + 'register_form': register_form + } + return TemplateResponse(request, 'login.html', data) + + +class Register(View): + ''' register a user ''' + def post(self, request): + ''' join the server ''' + if not models.SiteSettings.get().allow_registration: + invite_code = request.POST.get('invite_code') + + if not invite_code: + raise PermissionDenied + + invite = get_object_or_404(models.SiteInvite, code=invite_code) + if not invite.valid(): + raise PermissionDenied + else: + invite = None + + form = forms.RegisterForm(request.POST) + errors = False + if not form.is_valid(): + errors = True + + localname = form.data['localname'].strip() + email = form.data['email'] + password = form.data['password'] + + # check localname and email uniqueness + if models.User.objects.filter(localname=localname).first(): + form.errors['localname'] = [ + 'User with this username already exists'] + errors = True + + if errors: + data = { + 'login_form': forms.LoginForm(), + 'register_form': form, + 'invite': invite, + 'valid': invite.valid() if invite else True, + } + if invite: + return TemplateResponse(request, 'invite.html', data) + return TemplateResponse(request, 'login.html', data) + + username = '%s@%s' % (localname, DOMAIN) + user = models.User.objects.create_user( + username, email, password, localname=localname, local=True) + if invite: + invite.times_used += 1 + invite.save() + + login(request, user) + return redirect('/') + + +@method_decorator(login_required, name='dispatch') +class Logout(View): + ''' log out ''' + def get(self, request): + ''' done with this place! outa here! ''' + logout(request) + return redirect('/') diff --git a/bookwyrm/views/author.py b/bookwyrm/views/author.py new file mode 100644 index 000000000..ad9d18731 --- /dev/null +++ b/bookwyrm/views/author.py @@ -0,0 +1,66 @@ +''' the good people stuff! the authors! ''' +from django.contrib.auth.decorators import login_required, permission_required +from django.db.models import Q +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 bookwyrm import forms, models +from bookwyrm.activitypub import ActivitypubResponse +from bookwyrm.broadcast import broadcast +from .helpers import is_api_request + + +# pylint: disable= no-self-use +class Author(View): + ''' this person wrote a book ''' + def get(self, 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) + + +@method_decorator(login_required, name='dispatch') +@method_decorator( + permission_required('bookwyrm.edit_book', raise_exception=True), + name='dispatch') +class EditAuthor(View): + ''' edit author info ''' + def get(self, 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) + + def post(self, request, author_id): + ''' edit a author cool ''' + author = get_object_or_404(models.Author, id=author_id) + + form = forms.AuthorForm(request.POST, request.FILES, instance=author) + if not form.is_valid(): + data = { + 'title': 'Edit Author', + 'author': author, + 'form': form + } + return TemplateResponse(request, 'edit_author.html', data) + author = form.save() + + broadcast(request.user, author.to_update_activity(request.user)) + return redirect('/author/%s' % author.id) diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py new file mode 100644 index 000000000..553c74781 --- /dev/null +++ b/bookwyrm/views/books.py @@ -0,0 +1,228 @@ +''' the good stuff! the books! ''' +from django.core.paginator import Paginator +from django.contrib.auth.decorators import login_required, permission_required +from django.db import transaction +from django.db.models import Avg, Q +from django.http import 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 bookwyrm.connectors import connector_manager +from bookwyrm.settings import PAGE_LENGTH +from .helpers import is_api_request, get_activity_feed, get_edition + + +# pylint: disable= no-self-use +class Book(View): + ''' a book! this is the stuff ''' + def get(self, 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) + + 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, + } + return TemplateResponse(request, 'book.html', data) + + +@method_decorator(login_required, name='dispatch') +@method_decorator( + permission_required('bookwyrm.edit_book', raise_exception=True), + name='dispatch') +class EditBook(View): + ''' edit a book ''' + def get(self, 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) + + def post(self, 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) + + +class Editions(View): + ''' list of editions ''' + def get(self, 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) + + +@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) + + +@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 +@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) diff --git a/bookwyrm/views/direct_message.py b/bookwyrm/views/direct_message.py new file mode 100644 index 000000000..1f6c4f192 --- /dev/null +++ b/bookwyrm/views/direct_message.py @@ -0,0 +1,26 @@ +''' non-interactive pages ''' +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.views import View + +from bookwyrm.settings import PAGE_LENGTH +from .helpers import get_activity_feed + + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +class DirectMessage(View): + ''' dm view ''' + def get(self, 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) + data = { + 'title': 'Direct Messages', + 'user': request.user, + 'activities': activity_page, + } + return TemplateResponse(request, 'direct_messages.html', data) diff --git a/bookwyrm/views/error.py b/bookwyrm/views/error.py new file mode 100644 index 000000000..9eabe29fa --- /dev/null +++ b/bookwyrm/views/error.py @@ -0,0 +1,13 @@ +''' something has gone amiss ''' +from django.template.response import TemplateResponse + +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) diff --git a/bookwyrm/views/follow.py b/bookwyrm/views/follow.py new file mode 100644 index 000000000..08b6cca3f --- /dev/null +++ b/bookwyrm/views/follow.py @@ -0,0 +1,113 @@ +''' views for actions you can take in the application ''' +from django.db import transaction +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseBadRequest +from django.shortcuts import redirect +from django.views.decorators.http import require_POST + +from bookwyrm import models +from bookwyrm.broadcast import broadcast +from .helpers import get_user_from_username + +@login_required +@require_POST +def follow(request): + ''' follow another user, here or abroad ''' + username = request.POST['user'] + try: + to_follow = get_user_from_username(username) + except models.User.DoesNotExist: + return HttpResponseBadRequest() + + relationship, _ = models.UserFollowRequest.objects.get_or_create( + user_subject=request.user, + user_object=to_follow, + ) + activity = relationship.to_activity() + broadcast( + request.user, activity, privacy='direct', direct_recipients=[to_follow]) + return redirect(to_follow.local_path) + + +@login_required +@require_POST +def unfollow(request): + ''' unfollow a user ''' + username = request.POST['user'] + try: + to_unfollow = get_user_from_username(username) + except models.User.DoesNotExist: + return HttpResponseBadRequest() + + relationship = models.UserFollows.objects.get( + user_subject=request.user, + user_object=to_unfollow + ) + activity = relationship.to_undo_activity(request.user) + broadcast( + request.user, activity, + privacy='direct', direct_recipients=[to_unfollow]) + + to_unfollow.followers.remove(request.user) + return redirect(to_unfollow.local_path) + + +@login_required +@require_POST +def accept_follow_request(request): + ''' a user accepts a follow request ''' + username = request.POST['user'] + try: + requester = get_user_from_username(username) + except models.User.DoesNotExist: + return HttpResponseBadRequest() + + try: + follow_request = models.UserFollowRequest.objects.get( + user_subject=requester, + user_object=request.user + ) + except models.UserFollowRequest.DoesNotExist: + # Request already dealt with. + return redirect(request.user.local_path) + handle_accept(follow_request) + + return redirect(request.user.local_path) + + +def handle_accept(follow_request): + ''' send an acceptance message to a follow request ''' + user = follow_request.user_subject + to_follow = follow_request.user_object + with transaction.atomic(): + relationship = models.UserFollows.from_request(follow_request) + follow_request.delete() + relationship.save() + + activity = relationship.to_accept_activity() + broadcast(to_follow, activity, privacy='direct', direct_recipients=[user]) + + +@login_required +@require_POST +def delete_follow_request(request): + ''' a user rejects a follow request ''' + username = request.POST['user'] + try: + requester = get_user_from_username(username) + except models.User.DoesNotExist: + return HttpResponseBadRequest() + + try: + follow_request = models.UserFollowRequest.objects.get( + user_subject=requester, + user_object=request.user + ) + except models.UserFollowRequest.DoesNotExist: + return HttpResponseBadRequest() + + activity = follow_request.to_reject_activity() + follow_request.delete() + broadcast( + request.user, activity, privacy='direct', direct_recipients=[requester]) + return redirect('/user/%s' % request.user.localname) diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py new file mode 100644 index 000000000..3036e5683 --- /dev/null +++ b/bookwyrm/views/helpers.py @@ -0,0 +1,173 @@ +''' helper functions used in various views ''' +import re +from requests import HTTPError +from django.db.models import Q + +from bookwyrm import activitypub, models +from bookwyrm.broadcast import broadcast +from bookwyrm.connectors import ConnectorException, get_data +from bookwyrm.status import create_generated_note +from bookwyrm.utils import regex + + +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 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 + +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 + + +def handle_remote_webfinger(query): + ''' webfingerin' other servers ''' + user = None + + # usernames could be @user@domain or user@domain + if not query: + return None + + if query[0] == '@': + query = query[1:] + + try: + domain = query.split('@')[1] + except IndexError: + return None + + try: + user = models.User.objects.get(username=query) + except models.User.DoesNotExist: + url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \ + (domain, query) + try: + data = get_data(url) + except (ConnectorException, HTTPError): + return None + + for link in data.get('links'): + if link.get('rel') == 'self': + try: + user = activitypub.resolve_remote_id( + models.User, link['href'] + ) + 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 + + +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)) diff --git a/bookwyrm/views/import_data.py b/bookwyrm/views/import_data.py new file mode 100644 index 000000000..a8bfc8659 --- /dev/null +++ b/bookwyrm/views/import_data.py @@ -0,0 +1,83 @@ +''' import books from another app ''' +from io import TextIOWrapper + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.http import HttpResponseBadRequest +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 bookwyrm import forms, goodreads_import, models +from bookwyrm.tasks import app + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +class Import(View): + ''' import view ''' + def get(self, request): + ''' load import page ''' + return TemplateResponse(request, 'import.html', { + 'title': 'Import Books', + 'import_form': forms.ImportForm(), + 'jobs': models.ImportJob. + objects.filter(user=request.user).order_by('-created_date'), + }) + + def post(self, request): + ''' ingest a goodreads csv ''' + form = forms.ImportForm(request.POST, request.FILES) + if form.is_valid(): + include_reviews = request.POST.get('include_reviews') == 'on' + privacy = request.POST.get('privacy') + try: + job = goodreads_import.create_job( + request.user, + TextIOWrapper( + request.FILES['csv_file'], + encoding=request.encoding), + include_reviews, + privacy, + ) + except (UnicodeDecodeError, ValueError): + return HttpResponseBadRequest('Not a valid csv file') + goodreads_import.start_import(job) + return redirect('/import-status/%d' % job.id) + return HttpResponseBadRequest() + + +@method_decorator(login_required, name='dispatch') +class ImportStatus(View): + ''' status of an existing import ''' + def get(self, 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 + }) + + def post(self, request, job_id): + ''' retry lines from an import ''' + job = get_object_or_404(models.ImportJob, id=job_id) + items = [] + for item in request.POST.getlist('import_item'): + items.append(get_object_or_404(models.ImportItem, id=item)) + + job = goodreads_import.create_retry_job( + request.user, + job, + items, + ) + goodreads_import.start_import(job) + return redirect('/import-status/%d' % job.id) diff --git a/bookwyrm/views/interaction.py b/bookwyrm/views/interaction.py new file mode 100644 index 000000000..a6732c520 --- /dev/null +++ b/bookwyrm/views/interaction.py @@ -0,0 +1,130 @@ +''' boosts and favs ''' +from django.db import IntegrityError +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseBadRequest, HttpResponseNotFound +from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.views import View + +from bookwyrm import models +from bookwyrm.broadcast import broadcast +from bookwyrm.status import create_notification + + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +class Favorite(View): + ''' like a status ''' + def post(self, request, status_id): + ''' create a like ''' + status = models.Status.objects.get(id=status_id) + try: + favorite = models.Favorite.objects.create( + status=status, + user=request.user + ) + except IntegrityError: + # you already fav'ed that + return HttpResponseBadRequest() + + fav_activity = favorite.to_activity() + broadcast( + request.user, fav_activity, privacy='direct', + direct_recipients=[status.user]) + if status.user.local: + create_notification( + status.user, + 'FAVORITE', + related_user=request.user, + related_status=status + ) + return redirect(request.headers.get('Referer', '/')) + + +@method_decorator(login_required, name='dispatch') +class Unfavorite(View): + ''' take back a fav ''' + def post(self, request, status_id): + ''' unlike a status ''' + status = models.Status.objects.get(id=status_id) + try: + favorite = models.Favorite.objects.get( + status=status, + user=request.user + ) + except models.Favorite.DoesNotExist: + # can't find that status, idk + return HttpResponseNotFound() + + fav_activity = favorite.to_undo_activity(request.user) + favorite.delete() + broadcast(request.user, fav_activity, direct_recipients=[status.user]) + + # check for notification + if status.user.local: + notification = models.Notification.objects.filter( + user=status.user, related_user=request.user, + related_status=status, notification_type='FAVORITE' + ).first() + if notification: + notification.delete() + return redirect(request.headers.get('Referer', '/')) + + +@method_decorator(login_required, name='dispatch') +class Boost(View): + ''' boost a status ''' + def post(self, request, status_id): + ''' boost a status ''' + status = models.Status.objects.get(id=status_id) + # is it boostable? + if not status.boostable: + return HttpResponseBadRequest() + + if models.Boost.objects.filter( + boosted_status=status, user=request.user).exists(): + # you already boosted that. + return redirect(request.headers.get('Referer', '/')) + + boost = models.Boost.objects.create( + boosted_status=status, + privacy=status.privacy, + user=request.user, + ) + + boost_activity = boost.to_activity() + broadcast(request.user, boost_activity) + + if status.user.local: + create_notification( + status.user, + 'BOOST', + related_user=request.user, + related_status=status + ) + return redirect(request.headers.get('Referer', '/')) + + +@method_decorator(login_required, name='dispatch') +class Unboost(View): + ''' boost a status ''' + def post(self, request, status_id): + ''' boost a status ''' + status = models.Status.objects.get(id=status_id) + boost = models.Boost.objects.filter( + boosted_status=status, user=request.user + ).first() + activity = boost.to_undo_activity(request.user) + + boost.delete() + broadcast(request.user, activity) + + # delete related notification + if status.user.local: + notification = models.Notification.objects.filter( + user=status.user, related_user=request.user, + related_status=status, notification_type='BOOST' + ).first() + if notification: + notification.delete() + return redirect(request.headers.get('Referer', '/')) diff --git a/bookwyrm/views/invite.py b/bookwyrm/views/invite.py new file mode 100644 index 000000000..564858616 --- /dev/null +++ b/bookwyrm/views/invite.py @@ -0,0 +1,58 @@ +''' invites when registration is closed ''' +from django.contrib.auth.decorators import login_required, permission_required +from django.http import HttpResponseBadRequest +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 bookwyrm import forms, models + + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +@method_decorator( + permission_required('bookwyrm.create_invites', raise_exception=True), + name='dispatch') +class ManageInvites(View): + ''' create invites ''' + def get(self, 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) + + def post(self, request): + ''' creates an invite database entry ''' + form = forms.CreateInviteForm(request.POST) + if not form.is_valid(): + return HttpResponseBadRequest("ERRORS : %s" % (form.errors,)) + + invite = form.save(commit=False) + invite.user = request.user + invite.save() + + return redirect('/invite') + + +class Invite(View): + ''' use an invite to register ''' + def get(self, request, code): + ''' endpoint for using an 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) + + # post handling is in views.authentication.Register diff --git a/bookwyrm/views/landing.py b/bookwyrm/views/landing.py new file mode 100644 index 000000000..7917c5b48 --- /dev/null +++ b/bookwyrm/views/landing.py @@ -0,0 +1,122 @@ +''' non-interactive pages ''' +from django.contrib.auth.decorators import login_required +from django.core.paginator import Paginator +from django.db.models import Avg, Max +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.views import View + +from bookwyrm import forms, models +from bookwyrm.settings import PAGE_LENGTH +from .helpers import get_activity_feed + + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +class About(View): + ''' create invites ''' + def get(self, request): + ''' more information about the instance ''' + data = { + 'title': 'About', + } + return TemplateResponse(request, 'about.html', data) + +class Home(View): + ''' discover page or home feed depending on auth ''' + def get(self, request): + ''' this is the same as the feed on the home tab ''' + if request.user.is_authenticated: + feed_view = Feed.as_view() + return feed_view(request, 'home') + discover_view = Discover.as_view() + return discover_view(request) + +class Discover(View): + ''' preview of recently reviewed books ''' + def get(self, 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) + + +@method_decorator(login_required, name='dispatch') +class Feed(View): + ''' activity stream ''' + def get(self, 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) + data = { + 'title': 'Updates Feed', + 'user': request.user, + 'suggested_books': suggested_books, + 'activities': paginated.page(page), + 'tab': tab, + } + 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 diff --git a/bookwyrm/views/notifications.py b/bookwyrm/views/notifications.py new file mode 100644 index 000000000..7d6a31495 --- /dev/null +++ b/bookwyrm/views/notifications.py @@ -0,0 +1,29 @@ +''' non-interactive pages ''' +from django.contrib.auth.decorators import login_required +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.shortcuts import redirect +from django.views import View + + +# pylint: disable= no-self-use +@method_decorator(login_required, name='dispatch') +class Notifications(View): + ''' notifications view ''' + def get(self, request): + ''' people are interacting with you, get hyped ''' + 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) + + def post(self, request): + ''' permanently delete notification for user ''' + request.user.notification_set.filter(read=True).delete() + return redirect('/notifications') diff --git a/bookwyrm/views/outbox.py b/bookwyrm/views/outbox.py new file mode 100644 index 000000000..8bfc3b3d4 --- /dev/null +++ b/bookwyrm/views/outbox.py @@ -0,0 +1,22 @@ +''' the good stuff! the books! ''' +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.views import View + +from bookwyrm import activitypub, models + + +# pylint: disable= no-self-use +class Outbox(View): + ''' outbox ''' + def get(self, request, username): + ''' outbox for the requested user ''' + user = get_object_or_404(models.User, localname=username) + filter_type = request.GET.get('type') + if filter_type not in models.status_models: + filter_type = None + + return JsonResponse( + user.to_outbox(**request.GET, filter_type=filter_type), + encoder=activitypub.ActivityEncoder + ) diff --git a/bookwyrm/views/password.py b/bookwyrm/views/password.py new file mode 100644 index 000000000..915659e3e --- /dev/null +++ b/bookwyrm/views/password.py @@ -0,0 +1,102 @@ +''' class views for password management ''' +from django.contrib.auth import login +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.views import View + +from bookwyrm import models +from bookwyrm.emailing import password_reset_email + + +# pylint: disable= no-self-use +class PasswordResetRequest(View): + ''' forgot password flow ''' + def get(self, request): + ''' password reset page ''' + return TemplateResponse( + request, + 'password_reset_request.html', + {'title': 'Reset Password'} + ) + + def post(self, request): + ''' create a password reset token ''' + email = request.POST.get('email') + try: + user = models.User.objects.get(email=email) + except models.User.DoesNotExist: + return redirect('/password-reset') + + # remove any existing password reset cods for this user + models.PasswordReset.objects.filter(user=user).all().delete() + + # create a new reset code + code = models.PasswordReset.objects.create(user=user) + password_reset_email(code) + data = {'message': 'Password reset link sent to %s' % email} + return TemplateResponse(request, 'password_reset_request.html', data) + + +class PasswordReset(View): + ''' set new password ''' + def get(self, 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} + ) + + def post(self, request, code): + ''' allow a user to change their password through an emailed token ''' + try: + reset_code = models.PasswordReset.objects.get( + code=code + ) + except models.PasswordReset.DoesNotExist: + data = {'errors': ['Invalid password reset link']} + return TemplateResponse(request, 'password_reset.html', data) + + user = reset_code.user + + new_password = request.POST.get('password') + confirm_password = request.POST.get('confirm-password') + + if new_password != confirm_password: + data = {'errors': ['Passwords do not match']} + return TemplateResponse(request, 'password_reset.html', data) + + user.set_password(new_password) + user.save() + login(request, user) + reset_code.delete() + return redirect('/') + + +@method_decorator(login_required, name='dispatch') +class ChangePassword(View): + ''' change password as logged in user ''' + def post(self, request): + ''' allow a user to change their password ''' + new_password = request.POST.get('password') + confirm_password = request.POST.get('confirm-password') + + if new_password != confirm_password: + return redirect('/edit-profile') + + request.user.set_password(new_password) + request.user.save() + login(request, request.user) + return redirect('/user/%s' % request.user.localname) diff --git a/bookwyrm/views/reading.py b/bookwyrm/views/reading.py new file mode 100644 index 000000000..11b6e335e --- /dev/null +++ b/bookwyrm/views/reading.py @@ -0,0 +1,172 @@ +''' the good stuff! the books! ''' +import dateutil.parser +from dateutil.parser import ParserError + +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.utils import timezone +from django.views.decorators.http import require_POST + +from bookwyrm import models +from bookwyrm.broadcast import broadcast +from .helpers import get_edition, handle_reading_status +from .shelf import handle_unshelve + + +# pylint: disable= no-self-use +@login_required +@require_POST +def start_reading(request, book_id): + ''' begin reading a book ''' + book = get_edition(book_id) + shelf = models.Shelf.objects.filter( + identifier='reading', + user=request.user + ).first() + + # create a readthrough + readthrough = update_readthrough(request, book=book) + if readthrough: + readthrough.save() + + # shelve the book + 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.objects.create( + book=book, shelf=shelf, added_by=request.user) + broadcast(request.user, shelfbook.to_add_activity(request.user)) + + # post about it (if you want) + if request.POST.get('post-status'): + privacy = request.POST.get('privacy') + handle_reading_status(request.user, shelf, book, privacy) + + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def finish_reading(request, book_id): + ''' a user completed a book, yay ''' + book = get_edition(book_id) + shelf = models.Shelf.objects.filter( + identifier='read', + user=request.user + ).first() + + # update or create a readthrough + readthrough = update_readthrough(request, book=book) + if readthrough: + readthrough.save() + + # shelve the book + 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.objects.create( + book=book, shelf=shelf, added_by=request.user) + broadcast(request.user, shelfbook.to_add_activity(request.user)) + + # post about it (if you want) + if request.POST.get('post-status'): + privacy = request.POST.get('privacy') + handle_reading_status(request.user, shelf, book, privacy) + + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def edit_readthrough(request): + ''' can't use the form because the dates are too finnicky ''' + readthrough = update_readthrough(request, create=False) + if not readthrough: + return HttpResponseNotFound() + + # don't let people edit other people's data + if request.user != readthrough.user: + return HttpResponseBadRequest() + readthrough.save() + + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def delete_readthrough(request): + ''' remove a readthrough ''' + readthrough = get_object_or_404( + models.ReadThrough, id=request.POST.get('id')) + + # don't let people edit other people's data + if request.user != readthrough.user: + return HttpResponseBadRequest() + + readthrough.delete() + return redirect(request.headers.get('Referer', '/')) + + +@login_required +@require_POST +def create_readthrough(request): + ''' can't use the form because the dates are too finnicky ''' + book = get_object_or_404(models.Edition, id=request.POST.get('book')) + readthrough = update_readthrough(request, create=True, book=book) + if not readthrough: + return redirect(book.local_path) + readthrough.save() + return redirect(request.headers.get('Referer', '/')) + + +def update_readthrough(request, book=None, create=True): + ''' updates but does not save dates on a readthrough ''' + try: + read_id = request.POST.get('id') + if not read_id: + raise models.ReadThrough.DoesNotExist + readthrough = models.ReadThrough.objects.get(id=read_id) + except models.ReadThrough.DoesNotExist: + if not create or not book: + return None + readthrough = models.ReadThrough( + user=request.user, + book=book, + ) + + start_date = request.POST.get('start_date') + if start_date: + try: + start_date = timezone.make_aware(dateutil.parser.parse(start_date)) + readthrough.start_date = start_date + except ParserError: + pass + + finish_date = request.POST.get('finish_date') + if finish_date: + try: + finish_date = timezone.make_aware( + dateutil.parser.parse(finish_date)) + readthrough.finish_date = finish_date + except ParserError: + pass + + if not readthrough.start_date and not readthrough.finish_date: + return None + + return readthrough diff --git a/bookwyrm/views/search.py b/bookwyrm/views/search.py new file mode 100644 index 000000000..8066777a0 --- /dev/null +++ b/bookwyrm/views/search.py @@ -0,0 +1,53 @@ +''' search views''' +import re + +from django.contrib.postgres.search import TrigramSimilarity +from django.db.models.functions import Greatest +from django.http import JsonResponse +from django.template.response import TemplateResponse +from django.views import View + +from bookwyrm import models +from bookwyrm.connectors import connector_manager +from bookwyrm.utils import regex +from .helpers import is_api_request +from .helpers import handle_remote_webfinger + + +# pylint: disable= no-self-use +class Search(View): + ''' search users or books ''' + def get(self, 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 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): + 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) diff --git a/bookwyrm/views/shelf.py b/bookwyrm/views/shelf.py new file mode 100644 index 000000000..ce8369558 --- /dev/null +++ b/bookwyrm/views/shelf.py @@ -0,0 +1,170 @@ +''' 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.objects.create( + book=book, shelf=desired_shelf, added_by=request.user) + 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) diff --git a/bookwyrm/views/status.py b/bookwyrm/views/status.py new file mode 100644 index 000000000..39cd60775 --- /dev/null +++ b/bookwyrm/views/status.py @@ -0,0 +1,195 @@ +''' what are we here for if not for posting ''' +import re +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 markdown import markdown + +from bookwyrm import forms, models +from bookwyrm.activitypub import ActivitypubResponse +from bookwyrm.broadcast import broadcast +from bookwyrm.sanitize_html import InputHtmlParser +from bookwyrm.settings import DOMAIN +from bookwyrm.status import create_notification, delete_status +from bookwyrm.utils import regex +from .helpers import get_user_from_username, handle_remote_webfinger +from .helpers import is_api_request, is_bookworm_request, status_visible_to_user + + +# pylint: disable= no-self-use +class Status(View): + ''' the view for *posting* ''' + def get(self, 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) + + +@method_decorator(login_required, name='dispatch') +class CreateStatus(View): + ''' get posting ''' + def post(self, request, status_type): + ''' create status of whatever type ''' + status_type = status_type[0].upper() + status_type[1:] + try: + form = getattr(forms, '%sForm' % status_type)(request.POST) + except AttributeError: + return HttpResponseBadRequest() + if not form.is_valid(): + return redirect(request.headers.get('Referer', '/')) + + status = form.save(commit=False) + if not status.sensitive and status.content_warning: + # the cw text field remains populated when you click "remove" + status.content_warning = None + status.save() + + # inspect the text for user tags + content = status.content + for (mention_text, mention_user) in find_mentions(content): + # add them to status mentions fk + status.mention_users.add(mention_user) + + # turn the mention into a link + content = re.sub( + r'%s([^@]|$)' % mention_text, + r'%s\g<1>' % \ + (mention_user.remote_id, mention_text), + content) + + # add reply parent to mentions and notify + if status.reply_parent: + status.mention_users.add(status.reply_parent.user) + for mention_user in status.reply_parent.mention_users.all(): + status.mention_users.add(mention_user) + + if status.reply_parent.user.local: + create_notification( + status.reply_parent.user, + 'REPLY', + related_user=request.user, + related_status=status + ) + + # deduplicate mentions + status.mention_users.set(set(status.mention_users.all())) + # create mention notifications + for mention_user in status.mention_users.all(): + if status.reply_parent and mention_user == status.reply_parent.user: + continue + if mention_user.local: + create_notification( + mention_user, + 'MENTION', + related_user=request.user, + related_status=status + ) + + # don't apply formatting to generated notes + if not isinstance(status, models.GeneratedNote): + status.content = to_markdown(content) + # do apply formatting to quotes + if hasattr(status, 'quote'): + status.quote = to_markdown(status.quote) + + status.save() + + broadcast( + request.user, + status.to_create_activity(request.user), + software='bookwyrm') + + # re-format the activity for non-bookwyrm servers + remote_activity = status.to_create_activity(request.user, pure=True) + broadcast(request.user, remote_activity, software='other') + return redirect(request.headers.get('Referer', '/')) + + +class DeleteStatus(View): + ''' tombstone that bad boy ''' + def post(self, request, status_id): + ''' delete and tombstone a status ''' + status = get_object_or_404(models.Status, id=status_id) + + # don't let people delete other people's statuses + if status.user != request.user: + return HttpResponseBadRequest() + + # perform deletion + delete_status(status) + broadcast(request.user, status.to_delete_activity(request.user)) + return redirect(request.headers.get('Referer', '/')) + + +class Replies(View): + ''' replies page (a json view of status) ''' + def get(self, request, username, status_id): + ''' ordered collection of replies to a status ''' + # the html view is the same as Status + if not is_api_request(request): + status_view = Status.as_view() + return status_view(request, username, status_id) + + # the json view is different than Status + status = models.Status.objects.get(id=status_id) + if status.user.localname != username: + return HttpResponseNotFound() + + return ActivitypubResponse(status.to_replies(**request.GET)) + +def find_mentions(content): + ''' detect @mentions in raw status content ''' + for match in re.finditer(regex.strict_username, content): + username = match.group().strip().split('@')[1:] + if len(username) == 1: + # this looks like a local user (@user), fill in the domain + username.append(DOMAIN) + username = '@'.join(username) + + mention_user = handle_remote_webfinger(username) + if not mention_user: + # we can ignore users we don't know about + continue + yield (match.group(), mention_user) + + +def format_links(content): + ''' detect and format links ''' + return re.sub( + r'([^(href=")]|^|\()(https?:\/\/(%s([\w\.\-_\/+&\?=:;,])*))' % \ + regex.domain, + r'\g<1>\g<3>', + content) + +def to_markdown(content): + ''' catch links and convert to markdown ''' + content = format_links(content) + content = markdown(content) + # sanitize resulting html + sanitizer = InputHtmlParser() + sanitizer.feed(content) + return sanitizer.get_output() diff --git a/bookwyrm/views/tag.py b/bookwyrm/views/tag.py new file mode 100644 index 000000000..e95ffe817 --- /dev/null +++ b/bookwyrm/views/tag.py @@ -0,0 +1,78 @@ +''' tagging views''' +from django.contrib.auth.decorators import login_required +from django.http import 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 bookwyrm import models +from bookwyrm.activitypub import ActivitypubResponse +from bookwyrm.broadcast import broadcast +from .helpers import is_api_request + + +# pylint: disable= no-self-use +class Tag(View): + ''' tag page ''' + def get(self, request, tag_id): + ''' see 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) + + +@method_decorator(login_required, name='dispatch') +class AddTag(View): + ''' add a tag to a book ''' + def post(self, request): + ''' tag a book ''' + # I'm not using a form here because sometimes "name" is sent as a hidden + # field which doesn't validate + name = request.POST.get('name') + book_id = request.POST.get('book') + book = get_object_or_404(models.Edition, id=book_id) + tag_obj, created = models.Tag.objects.get_or_create( + name=name, + ) + user_tag, _ = models.UserTag.objects.get_or_create( + user=request.user, + book=book, + tag=tag_obj, + ) + + if created: + broadcast(request.user, user_tag.to_add_activity(request.user)) + return redirect('/book/%s' % book_id) + + +@method_decorator(login_required, name='dispatch') +class RemoveTag(View): + ''' remove a user's tag from a book ''' + def post(self, request): + ''' untag a book ''' + name = request.POST.get('name') + tag_obj = get_object_or_404(models.Tag, name=name) + book_id = request.POST.get('book') + book = get_object_or_404(models.Edition, id=book_id) + + user_tag = get_object_or_404( + models.UserTag, tag=tag_obj, book=book, user=request.user) + tag_activity = user_tag.to_remove_activity(request.user) + user_tag.delete() + + broadcast(request.user, tag_activity) + return redirect('/book/%s' % book_id) diff --git a/bookwyrm/views/user.py b/bookwyrm/views/user.py new file mode 100644 index 000000000..9a22c47cc --- /dev/null +++ b/bookwyrm/views/user.py @@ -0,0 +1,181 @@ +''' non-interactive pages ''' +from io import BytesIO +from uuid import uuid4 +from PIL import Image + +from django.contrib.auth.decorators import login_required +from django.core.files.base import ContentFile +from django.core.paginator import Paginator +from django.http import HttpResponseNotFound +from django.shortcuts import redirect +from django.template.response import TemplateResponse +from django.utils.decorators import method_decorator +from django.views import View + +from bookwyrm import forms, models +from bookwyrm.activitypub import ActivitypubResponse +from bookwyrm.broadcast import broadcast +from bookwyrm.settings import PAGE_LENGTH +from .helpers import get_activity_feed, get_user_from_username, is_api_request + + +# pylint: disable= no-self-use +class User(View): + ''' user profile page ''' + def get(self, 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) + data = { + 'title': user.name, + 'user': user, + 'is_self': is_self, + 'shelves': shelf_preview, + 'shelf_count': shelves.count(), + 'activities': paginated.page(page), + } + + return TemplateResponse(request, 'user.html', data) + +class Followers(View): + ''' list of followers view ''' + def get(self, 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) + +class Following(View): + ''' list of following view ''' + def get(self, 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) + + +@method_decorator(login_required, name='dispatch') +class EditUser(View): + ''' edit user view ''' + def get(self, 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) + + def post(self, request): + ''' les get fancy with images ''' + form = forms.EditUserForm( + request.POST, request.FILES, instance=request.user) + if not form.is_valid(): + data = {'form': form, 'user': request.user} + return TemplateResponse(request, 'edit_user.html', data) + + user = form.save(commit=False) + + if 'avatar' in form.files: + # crop and resize avatar upload + image = Image.open(form.files['avatar']) + target_size = 120 + width, height = image.size + thumbnail_scale = height / (width / target_size) if height > width \ + else width / (height / target_size) + image.thumbnail([thumbnail_scale, thumbnail_scale]) + width, height = image.size + + width_diff = width - target_size + height_diff = height - target_size + cropped = image.crop(( + int(width_diff / 2), + int(height_diff / 2), + int(width - (width_diff / 2)), + int(height - (height_diff / 2)) + )) + output = BytesIO() + cropped.save(output, format=image.format) + ContentFile(output.getvalue()) + + # set the name to a hash + extension = form.files['avatar'].name.split('.')[-1] + filename = '%s.%s' % (uuid4(), extension) + user.avatar.save(filename, ContentFile(output.getvalue())) + user.save() + + broadcast(user, user.to_update_activity(user)) + return redirect('/user/%s' % request.user.localname)