Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2020-12-31 14:37:24 -08:00
commit 42d50d15f8
42 changed files with 1197 additions and 177 deletions

View file

@ -11,6 +11,7 @@ from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person, PublicKey
from .response import ActivitypubResponse
from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject

View file

@ -0,0 +1,18 @@
from django.http import JsonResponse
from .base_activity import ActivityEncoder
class ActivitypubResponse(JsonResponse):
"""
A class to be used in any place that's serializing responses for
Activitypub enabled clients. Uses JsonResponse under the hood, but already
configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse.
"""
def __init__(self, data, encoder=ActivityEncoder, safe=True,
json_dumps_params=None, **kwargs):
if 'content_type' not in kwargs:
kwargs['content_type'] = 'application/activity+json'
super().__init__(data, encoder, safe, json_dumps_params, **kwargs)

View file

@ -5,6 +5,7 @@ from urllib.parse import urlparse
from requests import HTTPError
from bookwyrm import models
from bookwyrm.connectors import ConnectorException
from bookwyrm.tasks import app
@ -55,7 +56,7 @@ def search(query, min_confidence=0.1):
for connector in get_connectors():
try:
result_set = connector.search(query, min_confidence=min_confidence)
except HTTPError:
except (HTTPError, ConnectorException):
continue
result_set = [r for r in result_set \

View file

@ -3,7 +3,7 @@ import json
from django.utils.http import http_date
import requests
from bookwyrm import models
from bookwyrm import models, settings
from bookwyrm.activitypub import ActivityEncoder
from bookwyrm.tasks import app
from bookwyrm.signatures import make_signature, make_digest
@ -79,6 +79,7 @@ def sign_and_send(sender, data, destination):
'Digest': digest,
'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
},
)
if not response.ok:

View file

@ -1,6 +1,7 @@
''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod
from dataclasses import dataclass
from dataclasses import asdict, dataclass
import logging
from urllib3.exceptions import RequestError
from django.db import transaction
@ -8,9 +9,10 @@ import requests
from requests import HTTPError
from requests.exceptions import SSLError
from bookwyrm import activitypub, models
from bookwyrm import activitypub, models, settings
logger = logging.getLogger(__name__)
class ConnectorException(HTTPError):
''' when the connector can't do what was asked '''
@ -42,11 +44,16 @@ class AbstractMinimalConnector(ABC):
'%s%s' % (self.search_url, query),
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
},
)
if not resp.ok:
resp.raise_for_status()
data = resp.json()
try:
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException('Unable to parse json response', e)
results = []
for doc in self.parse_search_data(data)[:10]:
@ -196,6 +203,7 @@ def get_data(url):
url,
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
},
)
except RequestError:
@ -213,7 +221,12 @@ def get_data(url):
def get_image(url):
''' wrapper for requesting an image '''
try:
resp = requests.get(url)
resp = requests.get(
url,
headers={
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError):
return None
if not resp.ok:
@ -235,6 +248,12 @@ class SearchResult:
return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author)
def json(self):
''' serialize a connector for json response '''
serialized = asdict(self)
del serialized['connector']
return serialized
class Mapping:
''' associate a local database field with a field in an external dataset '''

View file

@ -68,7 +68,7 @@ class Connector(AbstractConnector):
key = data['key']
except KeyError:
raise ConnectorException('Invalid book data')
return '%s/%s' % (self.books_url, key)
return '%s%s' % (self.books_url, key)
def is_work_data(self, data):
@ -80,7 +80,7 @@ class Connector(AbstractConnector):
key = data['key']
except KeyError:
raise ConnectorException('Invalid book data')
url = '%s/%s/editions' % (self.books_url, key)
url = '%s%s/editions' % (self.books_url, key)
data = get_data(url)
return pick_default_edition(data['entries'])
@ -90,7 +90,7 @@ class Connector(AbstractConnector):
key = data['works'][0]['key']
except (IndexError, KeyError):
raise ConnectorException('No work found for edition')
url = '%s/%s' % (self.books_url, key)
url = '%s%s' % (self.books_url, key)
return get_data(url)
@ -100,7 +100,7 @@ class Connector(AbstractConnector):
author_blob = author_blob.get('author', author_blob)
# this id is "/authors/OL1234567A"
author_id = author_blob['key']
url = '%s/%s.json' % (self.base_url, author_id)
url = '%s%s' % (self.base_url, author_id)
yield self.get_or_create_author(url)
@ -130,7 +130,7 @@ class Connector(AbstractConnector):
def load_edition_data(self, olkey):
''' query openlibrary for editions of a work '''
url = '%s/works/%s/editions.json' % (self.books_url, olkey)
url = '%s/works/%s/editions' % (self.books_url, olkey)
return get_data(url)
@ -150,7 +150,7 @@ def get_description(description_blob):
''' descriptions can be a string or a dict '''
if isinstance(description_blob, dict):
return description_blob.get('value')
return description_blob
return description_blob
def get_openlibrary_key(key):

View file

@ -185,11 +185,15 @@ def handle_create(activity):
''' someone did something, good on them '''
# deduplicate incoming activities
activity = activity['object']
status_id = activity['id']
status_id = activity.get('id')
if models.Status.objects.filter(remote_id=status_id).count():
return
serializer = activitypub.activity_objects[activity['type']]
try:
serializer = activitypub.activity_objects[activity['type']]
except KeyError:
return
activity = serializer(**activity)
try:
model = models.activity_models[activity.type]

View file

@ -25,3 +25,6 @@ from .site import SiteSettings, SiteInvite, PasswordReset
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[1].activity_serializer.__name__: c[1] \
for c in cls_members if hasattr(c[1], 'activity_serializer')}
status_models = [
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]

View file

@ -35,6 +35,11 @@ class BookWyrmModel(models.Model):
''' this is just here to provide default fields for other models '''
abstract = True
@property
def local_path(self):
''' how to link to this object in the local app '''
return self.get_remote_id().replace('https://%s' % DOMAIN, '')
@receiver(models.signals.post_save)
#pylint: disable=unused-argument
@ -104,7 +109,7 @@ class ActivitypubMixin:
not field.deduplication_field:
continue
value = data.get(field.activitypub_field)
value = data.get(field.get_activitypub_field())
if not value:
continue
filters.append({field.name: value})
@ -237,7 +242,9 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
).serialize()
def to_ordered_collection_page(queryset, remote_id, id_only=False, page=1):
# pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, **kwargs):
''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH)

View file

@ -126,6 +126,14 @@ class Work(OrderedCollectionPageMixin, Book):
''' in case the default edition is not set '''
return self.default_edition or self.editions.first()
def to_edition_list(self, **kwargs):
''' an ordered collection of editions '''
return self.to_ordered_collection(
self.editions.order_by('-updated_date').all(),
remote_id='%s/editions' % self.remote_id,
**kwargs
)
activity_serializer = activitypub.Work
serialize_reverse_fields = [('editions', 'editions')]
deserialize_reverse_fields = [('editions', 'editions')]

View file

@ -17,7 +17,9 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(identifier=identifier)
return cls.objects.filter(
identifier=identifier
).order_by('-updated_date')
@property
def collection_queryset(self):
@ -64,7 +66,7 @@ class UserTag(BookWyrmModel):
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
target=self.remote_id,
).serialize()

View file

@ -1,6 +1,7 @@
''' database schema for user data '''
from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
@ -106,11 +107,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activity_serializer = activitypub.Person
def to_outbox(self, **kwargs):
def to_outbox(self, filter_type=None, **kwargs):
''' an ordered collection of statuses '''
queryset = Status.objects.filter(
if filter_type:
filter_class = apps.get_model(
'bookwyrm.%s' % filter_type, require_ready=True)
if not issubclass(filter_class, Status):
raise TypeError(
'filter_status_class must be a subclass of models.Status')
queryset = filter_class.objects
else:
queryset = Status.objects
queryset = queryset.filter(
user=self,
deleted=False,
privacy__in=['public', 'unlisted'],
).select_subclasses().order_by('-published_date')
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
@ -118,14 +130,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def to_following_activity(self, **kwargs):
''' activitypub following list '''
remote_id = '%s/following' % self.remote_id
return self.to_ordered_collection(self.following.all(), \
remote_id=remote_id, id_only=True, **kwargs)
return self.to_ordered_collection(
self.following.order_by('-updated_date').all(),
remote_id=remote_id,
id_only=True,
**kwargs
)
def to_followers_activity(self, **kwargs):
''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id
return self.to_ordered_collection(self.followers.all(), \
remote_id=remote_id, id_only=True, **kwargs)
return self.to_ordered_collection(
self.followers.order_by('-updated_date').all(),
remote_id=remote_id,
id_only=True,
**kwargs
)
def to_activity(self):
''' override default AP serializer to add context object
@ -167,6 +187,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return super().save(*args, **kwargs)
@property
def local_path(self):
''' this model doesn't inherit bookwyrm model, so here we are '''
return '/user/%s' % (self.localname or self.username)
class KeyPair(ActivitypubMixin, BookWyrmModel):
''' public and private keys for a user '''
@ -272,7 +297,7 @@ def get_or_create_remote_server(domain):
@app.task
def get_remote_reviews(outbox):
''' ingest reviews by a new remote bookwyrm user '''
outbox_page = outbox + '?page=true'
outbox_page = outbox + '?page=true&type=Review'
data = get_data(outbox_page)
# TODO: pagination?

View file

@ -2,8 +2,10 @@
import re
from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse
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
@ -20,19 +22,16 @@ from bookwyrm.utils import regex
@csrf_exempt
@require_GET
def outbox(request, username):
''' outbox for the requested user '''
if request.method != 'GET':
return HttpResponseNotFound()
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
try:
user = models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
# collection overview
return JsonResponse(
user.to_outbox(**request.GET),
user.to_outbox(**request.GET, filter_type=filter_type),
encoder=activitypub.ActivityEncoder
)
@ -284,21 +283,6 @@ def to_markdown(content):
return sanitizer.get_output()
def handle_tag(user, tag):
''' tag a book '''
broadcast(user, tag.to_add_activity(user))
def handle_untag(user, book, name):
''' tag a book '''
book = models.Book.objects.get(id=book)
tag = models.Tag.objects.get(name=name, book=book, user=user)
tag_activity = tag.to_remove_activity(user)
tag.delete()
broadcast(user, tag_activity)
def handle_favorite(user, status):
''' a user likes a status '''
try:

View file

@ -3,8 +3,11 @@ import os
from environs import Env
import requests
env = Env()
DOMAIN = env('DOMAIN')
VERSION = '0.0.1'
PAGE_LENGTH = env('PAGE_LENGTH', 15)
@ -150,3 +153,6 @@ STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, env('STATIC_ROOT', 'static'))
MEDIA_URL = '/images/'
MEDIA_ROOT = os.path.join(BASE_DIR, env('MEDIA_ROOT', 'images'))
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
requests.utils.default_user_agent(), VERSION, DOMAIN)

View file

@ -8,7 +8,7 @@
</div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="/author/{{ author.id }}/edit">
<a href="{{ author.local_path }}/edit">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit Author</span>
</span>

View file

@ -166,10 +166,10 @@
{% for rating in ratings %}
<div class="column">
<div class="media">
<div class="media-left">{% include 'snippets/avatar.html' %}</div>
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
<div class="media-content">
<div>
{% include 'snippets/username.html' %}
{% include 'snippets/username.html' with user=rating.user %}
</div>
<div class="field is-grouped mb-0">
<div>rated it</div>

View file

@ -31,7 +31,7 @@
</div>
{% endfor %}
{% if not following.count %}
<div>No one is following {{ user|username }}</div>
<div>{{ user|username }} isn't following any users</div>
{% endif %}
</div>

View file

@ -22,16 +22,16 @@
{% include 'snippets/username.html' with user=notification.related_user %}
{% if notification.notification_type == 'FAVORITE' %}
favorited your
<a href="{{ notification.related_status.remote_id}}">status</a>
<a href="{{ notification.related_status.local_path }}">status</a>
{% elif notification.notification_type == 'MENTION' %}
mentioned you in a
<a href="{{ notification.related_status.remote_id}}">status</a>
<a href="{{ notification.related_status.local_path }}">status</a>
{% elif notification.notification_type == 'REPLY' %}
<a href="{{ notification.related_status.remote_id}}">replied</a>
<a href="{{ notification.related_status.local_path }}">replied</a>
to your
<a href="{{ notification.related_status.reply_parent.remote_id}}">status</a>
<a href="{{ notification.related_status.reply_parent.local_path }}">status</a>
{% elif notification.notification_type == 'FOLLOW' %}
followed you
{% elif notification.notification_type == 'FOLLOW_REQUEST' %}
@ -41,7 +41,7 @@
</div>
{% elif notification.notification_type == 'BOOST' %}
boosted your <a href="{{ notification.related_status.remote_id}}">status</a>
boosted your <a href="{{ notification.related_status.local_path }}">status</a>
{% endif %}
{% else %}
your <a href="/import-status/{{ notification.related_import.id }}">import</a> completed.
@ -54,7 +54,7 @@
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
<div class="columns">
<div class="column">
<a href="{{ notification.related_status.remote_id }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
<a href="{{ notification.related_status.local_path }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
</div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ notification.related_status.published_date | post_date }}

View file

@ -122,7 +122,7 @@
<div class="block">
<div>
{% include 'snippets/shelf.html' with shelf=shelf ratings=ratings %}
{% include 'snippets/shelf.html' with shelf=shelf books=books ratings=ratings %}
</div>
</div>
{% endblock %}

View file

@ -1,13 +1,15 @@
{% load bookwyrm_tags %}
{% if request.user|follow_request_exists:user %}
<form action="/accept-follow-request/" method="POST">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-primary is-small" type="submit">Accept</button>
</form>
<form action="/delete-follow-request/" method="POST">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-danger is-light is-small" type="submit" class="warning">Delete</button>
</form>
<div class="field is-grouped">
<form action="/accept-follow-request/" method="POST">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-link is-small" type="submit">Accept</button>
</form>
<form action="/delete-follow-request/" method="POST">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-danger is-light is-small" type="submit" class="warning">Delete</button>
</form>
</div>
{% endif %}

View file

@ -1,20 +1,21 @@
{% load bookwyrm_tags %}
{% with activity.id|uuid as uuid %}
{% with status.id|uuid as uuid %}
<form class="is-flex-grow-1" name="reply" action="/reply" method="post" onsubmit="return reply(event)">
<div class="columns">
{% csrf_token %}
<input type="hidden" name="reply_parent" value="{{ activity.id }}">
<input type="hidden" name="reply_parent" value="{{ status.id }}">
<input type="hidden" name="user" value="{{ request.user.id }}">
<div class="column">
{% include 'snippets/content_warning_field.html' with parent_status=activity %}
{% include 'snippets/content_warning_field.html' with parent_status=status %}
<label for="id_content_{{ status.id }}-{{ uuid }}" class="is-sr-only">Reply</label>
<div class="field">
<textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ activity.id }}-{{ uuid }}" required="true"></textarea>
<textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ status.id }}-{{ uuid }}" required="true">{{ status|mentions:request.user }}</textarea>
</div>
</div>
<div class="column is-narrow">
<div class="field">
{% include 'snippets/privacy_select.html' with current=activity.privacy %}
{% include 'snippets/privacy_select.html' with current=status.privacy %}
</div>
<div class="field">
<button class="button is-primary" type="submit">

View file

@ -1,6 +1,6 @@
{% load humanize %}
{% load bookwyrm_tags %}
{% if shelf.books.all|length > 0 %}
{% if books|length > 0 %}
<table class="table is-striped is-fullwidth">
<tr class="book-preview">
@ -34,7 +34,7 @@
</th>
{% endif %}
</tr>
{% for book in shelf.books.all %}
{% for book in books %}
<tr class="book-preview">
<td>
{% include 'snippets/book_cover.html' with book=book size="small" %}

View file

@ -5,7 +5,7 @@
<input type="hidden" name="name" value="{{ tag.tag.name }}">
<div class="tags has-addons">
<a class="tag" href="/tag/{{ tag.tag.identifier|urlencode }}">
<a class="tag" href="{{ tag.tag.local_path }}">
{{ tag.tag.name }}
</a>
{% if tag.tag.identifier in user_tags %}

View file

@ -5,7 +5,7 @@
<div class="column is-narrow">
<div class="media">
<div class="media-left">
<a href="/user/{{ user|username }}">
<a href="{{ user.local_path }}">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
</div>
@ -14,8 +14,8 @@
<p><a href="{{ user.remote_id }}">{{ user.username }}</a></p>
<p>Joined {{ user.created_date | naturaltime }}</p>
<p>
<a href="/user/{{ user | username }}/followers">{{ user.followers.count }} follower{{ user.followers.count | pluralize }}</a>,
<a href="/user/{{ user | username }}/following">{{ user.following.count }} following</a>
<a href="{{ user.local_path }}/followers">{{ user.followers.count }} follower{{ user.followers.count | pluralize }}</a>,
<a href="{{ user.local_path }}/following">{{ user.following.count }} following</a>
</p>
</div>
</div>

View file

@ -1,2 +1,2 @@
{% load bookwyrm_tags %}
<a href="/user/{{ user | username }}" class="user">{% if user.name %}{{ user.name }}{% else %}{{ user | username }}{% endif %}</a>{% if possessive %}'s{% endif %}{% if show_full and user.name or show_full and user.localname %} ({{ user.username }}){% endif %}
<a href="{{ user.local_path }}" class="user">{% if user.name %}{{ user.name }}{% else %}{{ user | username }}{% endif %}</a>{% if possessive %}'s{% endif %}{% if show_full and user.name or show_full and user.localname %} ({{ user.username }}){% endif %}

View file

@ -24,11 +24,11 @@
{% for shelf in shelves %}
<div class="column is-narrow">
<h3>{{ shelf.name }}
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.remote_id }}">See all {{ shelf.size }}</a>)</small>{% endif %}</h3>
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.local_path }}">See all {{ shelf.size }}</a>)</small>{% endif %}</h3>
<div class="is-mobile field is-grouped">
{% for book in shelf.books %}
<div class="control">
<a href="/book/{{ book.id }}">
<a href="{{ book.local_path }}">
{% include 'snippets/book_cover.html' with book=book size="medium" %}
</a>
</div>
@ -37,7 +37,7 @@
</div>
{% endfor %}
</div>
<small><a href="/user/{{ user.localname }}/shelves">See all {{ shelf_count }} shelves</a></small>
<small><a href="{{ user.local_path }}/shelves">See all {{ shelf_count }} shelves</a></small>
</div>
<div>

View file

@ -110,7 +110,7 @@ def get_uuid(identifier):
return '%s%s' % (identifier, uuid4())
@register.filter(name="post_date")
@register.filter(name='post_date')
def time_since(date):
''' concise time ago function '''
if not isinstance(date, datetime):
@ -133,13 +133,20 @@ def time_since(date):
return '%ds' % delta.seconds
@register.filter(name="to_markdown")
@register.filter(name='to_markdown')
def get_markdown(content):
''' convert markdown to html '''
if content:
return to_markdown(content)
return None
@register.filter(name='mentions')
def get_mentions(status, user):
''' anyone tagged or replied to in this status '''
mentions = set([status.user] + list(status.mention_users.all()))
return ' '.join(
'@' + get_user_identifier(m) for m in mentions if not m == user)
@register.simple_tag(takes_context=True)
def active_shelf(context, book):
''' check what shelf a user has a book on, if any '''

View file

@ -1,60 +1,111 @@
''' testing book data connectors '''
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.connectors import abstract_connector
from bookwyrm.connectors.abstract_connector import Mapping
from bookwyrm.connectors.openlibrary import Connector
from bookwyrm.settings import DOMAIN
class AbstractConnector(TestCase):
''' generic code for connecting to outside data sources '''
def setUp(self):
self.book = models.Edition.objects.create(title='Example Edition')
models.Connector.objects.create(
''' we need an example connector '''
self.connector_info = models.Connector.objects.create(
identifier='example.com',
connector_file='openlibrary',
base_url='https://example.com',
books_url='https:/example.com',
covers_url='https://example.com',
books_url='https://example.com/books',
covers_url='https://example.com/covers',
search_url='https://example.com/search?q=',
)
self.connector = Connector('example.com')
self.data = {
'title': 'Unused title',
'ASIN': 'A00BLAH',
'isbn_10': '1234567890',
'isbn_13': 'blahhh',
'blah': 'bip',
'format': 'hardcover',
'series': ['one', 'two'],
work_data = {
'id': 'abc1',
'title': 'Test work',
'type': 'work',
'openlibraryKey': 'OL1234W',
}
self.connector.key_mappings = [
Mapping('isbn_10'),
Mapping('isbn_13'),
Mapping('lccn'),
Mapping('asin'),
self.work_data = work_data
edition_data = {
'id': 'abc2',
'title': 'Test edition',
'type': 'edition',
'openlibraryKey': 'OL1234M',
}
self.edition_data = edition_data
class TestConnector(abstract_connector.AbstractConnector):
''' nothing added here '''
def format_search_result(self, search_result):
return search_result
def parse_search_data(self, data):
return data
def is_work_data(self, data):
return data['type'] == 'work'
def get_edition_from_work_data(self, data):
return edition_data
def get_work_from_edition_data(self, data):
return work_data
def get_authors_from_data(self, data):
return []
def expand_book_data(self, book):
pass
self.connector = TestConnector('example.com')
self.connector.book_mappings = [
Mapping('id'),
Mapping('title'),
Mapping('openlibraryKey'),
]
def test_create_mapping(self):
mapping = Mapping('isbn')
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.formatter('bb'), 'bb')
self.book = models.Edition.objects.create(
title='Test Book', remote_id='https://example.com/book/1234',
openlibrary_key='OL1234M')
def test_create_mapping_with_remote(self):
mapping = Mapping('isbn', remote_field='isbn13')
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn13')
self.assertEqual(mapping.formatter('bb'), 'bb')
def test_abstract_connector_init(self):
''' barebones connector for search with defaults '''
self.assertIsInstance(self.connector.book_mappings, list)
def test_create_mapping_with_formatter(self):
formatter = lambda x: 'aa' + x
mapping = Mapping('isbn', formatter=formatter)
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.formatter, formatter)
self.assertEqual(mapping.formatter('bb'), 'aabb')
def test_is_available(self):
''' this isn't used.... '''
self.assertTrue(self.connector.is_available())
self.connector.max_query_count = 1
self.connector.connector.query_count = 2
self.assertFalse(self.connector.is_available())
def test_get_or_create_book_existing(self):
''' find an existing book by remote/origin id '''
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(
self.book.remote_id, 'https://%s/book/%d' % (DOMAIN, self.book.id))
self.assertEqual(
self.book.origin_id, 'https://example.com/book/1234')
# dedupe by origin id
result = self.connector.get_or_create_book(
'https://example.com/book/1234')
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(result, self.book)
# dedupe by remote id
result = self.connector.get_or_create_book(
'https://%s/book/%d' % (DOMAIN, self.book.id))
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(result, self.book)
@responses.activate
def test_get_or_create_book_deduped(self):
''' load remote data and deduplicate '''
responses.add(
responses.GET,
'https://example.com/book/abcd',
json=self.edition_data
)
result = self.connector.get_or_create_book(
'https://example.com/book/abcd')
self.assertEqual(result, self.book)
self.assertEqual(models.Edition.objects.count(), 1)
self.assertEqual(models.Edition.objects.count(), 1)

View file

@ -0,0 +1,100 @@
''' testing book data connectors '''
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.connectors import abstract_connector
from bookwyrm.connectors.abstract_connector import Mapping, SearchResult
class AbstractConnector(TestCase):
''' generic code for connecting to outside data sources '''
def setUp(self):
''' we need an example connector '''
self.connector_info = 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=',
)
class TestConnector(abstract_connector.AbstractMinimalConnector):
''' nothing added here '''
def format_search_result(self, search_result):
return search_result
def get_or_create_book(self, remote_id):
pass
def parse_search_data(self, data):
return data
self.test_connector = TestConnector('example.com')
def test_abstract_minimal_connector_init(self):
''' barebones connector for search with defaults '''
connector = self.test_connector
self.assertEqual(connector.connector, self.connector_info)
self.assertEqual(connector.base_url, 'https://example.com')
self.assertEqual(connector.books_url, 'https://example.com/books')
self.assertEqual(connector.covers_url, 'https://example.com/covers')
self.assertEqual(connector.search_url, 'https://example.com/search?q=')
self.assertIsNone(connector.name)
self.assertEqual(connector.identifier, 'example.com')
self.assertIsNone(connector.max_query_count)
self.assertFalse(connector.local)
@responses.activate
def test_search(self):
''' makes an http request to the outside service '''
responses.add(
responses.GET,
'https://example.com/search?q=a%20book%20title',
json=['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'],
status=200)
results = self.test_connector.search('a book title')
self.assertEqual(len(results), 10)
self.assertEqual(results[0], 'a')
self.assertEqual(results[1], 'b')
self.assertEqual(results[2], 'c')
def test_search_result(self):
''' a class that stores info about a search result '''
result = SearchResult(
title='Title',
key='https://example.com/book/1',
author='Author Name',
year='1850',
connector=self.test_connector,
)
# there's really not much to test here, it's just a dataclass
self.assertEqual(result.confidence, 1)
self.assertEqual(result.title, 'Title')
def test_create_mapping(self):
''' maps remote fields for book data to bookwyrm activitypub fields '''
mapping = Mapping('isbn')
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.formatter('bb'), 'bb')
def test_create_mapping_with_remote(self):
''' the remote field is different than the local field '''
mapping = Mapping('isbn', remote_field='isbn13')
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn13')
self.assertEqual(mapping.formatter('bb'), 'bb')
def test_create_mapping_with_formatter(self):
''' a function is provided to modify the data '''
formatter = lambda x: 'aa' + x
mapping = Mapping('isbn', formatter=formatter)
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.formatter, formatter)
self.assertEqual(mapping.formatter('bb'), 'aabb')

View file

@ -31,6 +31,7 @@ class BookWyrmConnector(TestCase):
def test_format_search_result(self):
''' create a SearchResult object from search response json '''
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/fr_search.json')
search_data = json.loads(datafile.read_bytes())
@ -43,3 +44,4 @@ class BookWyrmConnector(TestCase):
self.assertEqual(result.key, 'https://example.com/book/122')
self.assertEqual(result.author, 'Susanna Clarke')
self.assertEqual(result.year, 2017)
self.assertEqual(result.connector, self.connector)

View file

@ -1,9 +1,10 @@
''' testing book data connectors '''
import json
import pathlib
from dateutil import parser
from unittest.mock import patch
from django.test import TestCase
import pytz
import responses
from bookwyrm import models
from bookwyrm.connectors.openlibrary import Connector
@ -11,10 +12,13 @@ from bookwyrm.connectors.openlibrary import get_languages, get_description
from bookwyrm.connectors.openlibrary import pick_default_edition, \
get_openlibrary_key
from bookwyrm.connectors.abstract_connector import SearchResult
from bookwyrm.connectors.abstract_connector import ConnectorException
class Openlibrary(TestCase):
''' test loading data from openlibrary.org '''
def setUp(self):
''' creates the connector we'll use '''
models.Connector.objects.create(
identifier='openlibrary.org',
name='OpenLibrary',
@ -37,19 +41,85 @@ class Openlibrary(TestCase):
self.edition_list_data = json.loads(edition_list_file.read_bytes())
def test_get_remote_id_from_data(self):
''' format the remote id from the data '''
data = {'key': '/work/OL1234W'}
result = self.connector.get_remote_id_from_data(data)
self.assertEqual(result, 'https://openlibrary.org/work/OL1234W')
# error handlding
with self.assertRaises(ConnectorException):
self.connector.get_remote_id_from_data({})
def test_is_work_data(self):
''' detect if the loaded json is a work '''
self.assertEqual(self.connector.is_work_data(self.work_data), True)
self.assertEqual(self.connector.is_work_data(self.edition_data), False)
def test_pick_default_edition(self):
edition = pick_default_edition(self.edition_list_data['entries'])
self.assertEqual(edition['key'], '/books/OL9788823M')
@responses.activate
def test_get_edition_from_work_data(self):
''' loads a list of editions '''
data = {'key': '/work/OL1234W'}
responses.add(
responses.GET,
'https://openlibrary.org/work/OL1234W/editions',
json={'entries': []},
status=200)
with patch('bookwyrm.connectors.openlibrary.pick_default_edition') \
as pick_edition:
pick_edition.return_value = 'hi'
result = self.connector.get_edition_from_work_data(data)
self.assertEqual(result, 'hi')
@responses.activate
def test_get_work_from_edition_data(self):
''' loads a list of editions '''
data = {'works': [{'key': '/work/OL1234W'}]}
responses.add(
responses.GET,
'https://openlibrary.org/work/OL1234W',
json={'hi': 'there'},
status=200)
result = self.connector.get_work_from_edition_data(data)
self.assertEqual(result, {'hi': 'there'})
@responses.activate
def test_get_authors_from_data(self):
''' find authors in data '''
responses.add(
responses.GET,
'https://openlibrary.org/authors/OL382982A',
json={'hi': 'there'},
status=200)
results = self.connector.get_authors_from_data(self.work_data)
for result in results:
self.assertIsInstance(result, models.Author)
def test_get_cover_url(self):
''' formats a url that should contain the cover image '''
blob = ['image']
result = self.connector.get_cover_url(blob)
self.assertEqual(
result, 'https://covers.openlibrary.org/b/id/image-M.jpg')
def test_parse_search_result(self):
''' extract the results from the search json response '''
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ol_search.json')
search_data = json.loads(datafile.read_bytes())
result = self.connector.parse_search_data(search_data)
self.assertIsInstance(result, list)
self.assertEqual(len(result), 2)
def test_format_search_result(self):
''' translate json from openlibrary into SearchResult '''
datafile = pathlib.Path(__file__).parent.joinpath('../data/ol_search.json')
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ol_search.json')
search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_search_data(search_data)
self.assertIsInstance(results, list)
@ -57,22 +127,66 @@ class Openlibrary(TestCase):
result = self.connector.format_search_result(results[0])
self.assertIsInstance(result, SearchResult)
self.assertEqual(result.title, 'This Is How You Lose the Time War')
self.assertEqual(result.key, 'https://openlibrary.org/works/OL20639540W')
self.assertEqual(
result.key, 'https://openlibrary.org/works/OL20639540W')
self.assertEqual(result.author, 'Amal El-Mohtar, Max Gladstone')
self.assertEqual(result.year, 2019)
self.assertEqual(result.connector, self.connector)
@responses.activate
def test_load_edition_data(self):
''' format url from key and make request '''
key = 'OL1234W'
responses.add(
responses.GET,
'https://openlibrary.org/works/OL1234W/editions',
json={'hi': 'there'}
)
result = self.connector.load_edition_data(key)
self.assertEqual(result, {'hi': 'there'})
@responses.activate
def test_expand_book_data(self):
''' given a book, get more editions '''
work = models.Work.objects.create(
title='Test Work', openlibrary_key='OL1234W')
edition = models.Edition.objects.create(
title='Test Edition', parent_work=work)
responses.add(
responses.GET,
'https://openlibrary.org/works/OL1234W/editions',
json={'entries': []},
)
with patch(
'bookwyrm.connectors.abstract_connector.AbstractConnector.' \
'create_edition_from_data'):
self.connector.expand_book_data(edition)
self.connector.expand_book_data(work)
def test_get_description(self):
''' should do some cleanup on the description data '''
description = get_description(self.work_data['description'])
expected = 'First in the Old Kingdom/Abhorsen series.'
self.assertEqual(description, expected)
def test_get_openlibrary_key(self):
''' extracts the uuid '''
key = get_openlibrary_key('/books/OL27320736M')
self.assertEqual(key, 'OL27320736M')
def test_get_languages(self):
''' looks up languages from a list '''
languages = get_languages(self.edition_data['languages'])
self.assertEqual(languages, ['English'])
def test_get_ol_key(self):
key = get_openlibrary_key('/books/OL27320736M')
self.assertEqual(key, 'OL27320736M')
def test_pick_default_edition(self):
''' detect if the loaded json is an edition '''
edition = pick_default_edition(self.edition_list_data['entries'])
self.assertEqual(edition['key'], '/books/OL9788823M')

View file

@ -200,3 +200,15 @@ class BaseModel(TestCase):
# test subclass match
result = models.Status.find_existing_by_remote_id(
'https://comment.net')
def test_find_existing(self):
''' match a blob of data to a model '''
book = models.Edition.objects.create(
title='Test edition',
openlibrary_key='OL1234',
)
result = models.Edition.find_existing(
{'openlibraryKey': 'OL1234'})
self.assertEqual(result, book)

View file

@ -154,12 +154,12 @@ class ImportJob(TestCase):
status=200)
responses.add(
responses.GET,
'https://openlibrary.org//works/OL15832982W',
'https://openlibrary.org/works/OL15832982W',
json=bookdata,
status=200)
responses.add(
responses.GET,
'https://openlibrary.org//authors/OL382982A.json',
'https://openlibrary.org/authors/OL382982A',
json={'name': 'test author'},
status=200)

View file

@ -272,6 +272,12 @@ class Incoming(TestCase):
incoming.handle_create(activity)
self.assertEqual(models.Status.objects.count(), 2)
def test_handle_create_unknown_type(self):
''' folks send you all kinds of things '''
activity = {'object': {'id': 'hi'}, 'type': 'Fish'}
result = incoming.handle_create(activity)
self.assertIsNone(result)
def test_handle_create_remote_note_with_mention(self):
''' should only create it under the right circumstances '''
self.assertEqual(models.Status.objects.count(), 1)

View file

@ -3,7 +3,9 @@ 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 models, outgoing
@ -14,6 +16,7 @@ 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@rat.com', 'ratword',
@ -24,7 +27,7 @@ class Outgoing(TestCase):
)
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', local=True,
remote_id='https://example.com/users/mouse',
localname='mouse', remote_id='https://example.com/users/mouse',
)
datafile = pathlib.Path(__file__).parent.joinpath(
@ -46,6 +49,67 @@ class Outgoing(TestCase):
)
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)

View file

@ -41,6 +41,9 @@ class ViewActions(TestCase):
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()
@ -351,3 +354,43 @@ class ViewActions(TestCase):
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
self.assertEqual(resp.template_name, 'edit_author.html')
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!?')
user_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())

View file

@ -0,0 +1,532 @@
''' 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.connectors import abstract_connector
from bookwyrm.settings import DOMAIN
# 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', 'mouse@mouse.mouse', 'password', local=True)
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_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@%s' % DOMAIN), 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, 'home')
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_mention)
statuses = views.get_activity_feed(
self.local_user, 'home', model=models.Comment)
self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], public_status)
statuses = views.get_activity_feed(self.local_user, 'local')
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, 'federated')
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, 'friends')
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, 'friends')
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.books_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_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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
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, JsonResponse)
self.assertEqual(result.status_code, 200)

View file

@ -5,11 +5,10 @@ from django.urls import path, re_path
from bookwyrm import incoming, outgoing, views, settings, wellknown
from bookwyrm import view_actions as actions
from bookwyrm.utils import regex
username_regex = r'(?P<username>[\w\-_\.]+@[\w\-\_\.]+)'
localname_regex = r'(?P<username>[\w\-_\.]+)'
user_path = r'^user/%s' % username_regex
local_user_path = r'^user/%s' % localname_regex
user_path = r'^user/(?P<username>%s)' % regex.username
local_user_path = r'^user/(?P<username>%s)' % regex.localname
status_types = [
'status',
@ -20,7 +19,7 @@ status_types = [
'generatednote'
]
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
(local_user_path, '|'.join(status_types))
(user_path, '|'.join(status_types))
book_path = r'^book/(?P<book_id>\d+)'

View file

@ -1,5 +1,6 @@
''' defining regexes for regularly used concepts '''
domain = r'[a-z-A-Z0-9_\-]+\.[a-z]+'
username = r'@[a-zA-Z_\-\.0-9]+(@%s)?' % domain
full_username = r'@?[a-zA-Z_\-\.0-9]+@%s' % domain
localname = r'@?[a-zA-Z_\-\.0-9]+'
username = r'%s(@%s)?' % (localname, domain)
full_username = r'%s@%s' % (localname, domain)

View file

@ -577,14 +577,14 @@ def tag(request):
tag_obj, created = models.Tag.objects.get_or_create(
name=name,
)
user_tag = models.UserTag.objects.get_or_create(
user_tag, _ = models.UserTag.objects.get_or_create(
user=request.user,
book=book,
tag=tag_obj,
)
if created:
outgoing.handle_tag(request.user, user_tag)
broadcast(request.user, user_tag.to_add_activity(request.user))
return redirect('/book/%s' % book_id)
@ -593,9 +593,16 @@ def tag(request):
def untag(request):
''' untag a book '''
name = request.POST.get('name')
tag = get_object_or_404(models.Tag, name=name)
book_id = request.POST.get('book')
book = get_object_or_404(models.Edition, id=book_id)
outgoing.handle_untag(request.user, book_id, name)
tag = get_object_or_404(
models.UserTag, tag=tag, book=book, user=request.user)
tag_activity = tag.to_remove_activity(request.user)
tag.delete()
broadcast(request.user, tag_activity)
return redirect('/book/%s' % book_id)

View file

@ -13,7 +13,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from bookwyrm import outgoing
from bookwyrm.activitypub import ActivityEncoder
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm import forms, models, books_manager
from bookwyrm import goodreads_import
from bookwyrm.settings import PAGE_LENGTH
@ -23,11 +23,11 @@ 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:
user = models.User.objects.get(localname=username)
return models.User.objects.get(localname=username)
except models.User.DoesNotExist:
user = models.User.objects.get(username=username)
return user
return models.User.objects.get(username=username)
def is_api_request(request):
@ -38,12 +38,14 @@ def is_api_request(request):
def server_error_page(request):
''' 500 errors '''
return TemplateResponse(request, 'error.html', {'title': 'Oops!'})
return TemplateResponse(
request, 'error.html', {'title': 'Oops!'}, status=500)
def not_found_page(request, _):
''' 404s '''
return TemplateResponse(request, 'notfound.html', {'title': 'Not found'})
return TemplateResponse(
request, 'notfound.html', {'title': 'Not found'}, status=404)
@login_required
@ -210,7 +212,7 @@ def search(request):
if is_api_request(request):
# only return local book results via json so we don't cause a cascade
book_results = books_manager.local_search(query)
return JsonResponse([r.__dict__ for r in book_results], safe=False)
return JsonResponse([r.json() for r in book_results], safe=False)
# use webfinger for mastodon style account@domain.com username
if re.match(regex.full_username, query):
@ -378,7 +380,7 @@ def user_page(request, username):
if is_api_request(request):
# we have a json request
return JsonResponse(user.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(user.to_activity())
# otherwise we're at a UI view
try:
@ -403,7 +405,7 @@ def user_page(request, username):
continue
shelf_preview.append({
'name': user_shelf.name,
'remote_id': user_shelf.remote_id,
'local_path': user_shelf.local_path,
'books': user_shelf.books.all()[:3],
'size': user_shelf.books.count(),
})
@ -446,7 +448,7 @@ def followers_page(request, username):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(user.to_followers_activity(**request.GET))
return ActivitypubResponse(user.to_followers_activity(**request.GET))
data = {
'title': '%s: followers' % user.name,
@ -467,7 +469,7 @@ def following_page(request, username):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(user.to_following_activity(**request.GET))
return ActivitypubResponse(user.to_following_activity(**request.GET))
data = {
'title': '%s: following' % user.name,
@ -497,7 +499,7 @@ def status_page(request, username, status_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(status.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(status.to_activity())
data = {
'title': 'Status by %s' % user.username,
@ -530,10 +532,7 @@ def replies_page(request, username, status_id):
if status.user.localname != username:
return HttpResponseNotFound()
return JsonResponse(
status.to_replies(**request.GET),
encoder=ActivityEncoder
)
return ActivitypubResponse(status.to_replies(**request.GET))
@login_required
@ -565,7 +564,7 @@ def book_page(request, book_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(book.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(book.to_activity())
if isinstance(book, models.Work):
book = book.get_default_edition()
@ -677,10 +676,7 @@ def editions_page(request, book_id):
work = get_object_or_404(models.Work, id=book_id)
if is_api_request(request):
return JsonResponse(
work.to_edition_list(**request.GET),
encoder=ActivityEncoder
)
return ActivitypubResponse(work.to_edition_list(**request.GET))
data = {
'title': 'Editions of %s' % work.title,
@ -696,7 +692,7 @@ def author_page(request, author_id):
author = get_object_or_404(models.Author, id=author_id)
if is_api_request(request):
return JsonResponse(author.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(author.to_activity())
books = models.Work.objects.filter(
Q(authors=author) | Q(editions__authors=author)).distinct()
@ -716,8 +712,7 @@ def tag_page(request, tag_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(
tag_obj.to_activity(**request.GET), encoder=ActivityEncoder)
return ActivitypubResponse(tag_obj.to_activity(**request.GET))
books = models.Edition.objects.filter(
usertag__tag__identifier=tag_id
@ -768,7 +763,11 @@ def shelf_page(request, username, shelf_identifier):
if is_api_request(request):
return JsonResponse(shelf.to_activity(**request.GET))
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),
@ -776,6 +775,7 @@ def shelf_page(request, username, shelf_identifier):
'is_self': is_self,
'shelves': shelves.all(),
'shelf': shelf,
'books': [b.book for b in books],
}
return TemplateResponse(request, 'shelf.html', data)

View file

@ -6,7 +6,7 @@ from django.http import JsonResponse
from django.utils import timezone
from bookwyrm import models
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import DOMAIN, VERSION
def webfinger(request):
@ -76,7 +76,7 @@ def nodeinfo(request):
'version': '2.0',
'software': {
'name': 'bookwyrm',
'version': '0.0.1'
'version': VERSION
},
'protocols': [
'activitypub'