Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2020-12-19 20:33:36 -08:00
commit b910be99c3
22 changed files with 485 additions and 394 deletions

View file

@ -74,6 +74,9 @@ class ActivityObject:
model.activity_serializer)
)
if hasattr(model, 'ignore_activity') and model.ignore_activity(self):
return instance
# check for an existing instance, if we're not updating a known obj
instance = instance or model.find_existing(self.serialize()) or model()

View file

@ -50,7 +50,7 @@ class Work(Book):
''' work instance of a book object '''
lccn: str = ''
defaultEdition: str = ''
editions: List[str]
editions: List[str] = field(default_factory=lambda: [])
type: str = 'Work'
@ -58,9 +58,9 @@ class Work(Book):
class Author(ActivityObject):
''' author of a book '''
name: str
born: str = ''
died: str = ''
aliases: str = ''
born: str = None
died: str = None
aliases: List[str] = field(default_factory=lambda: [])
bio: str = ''
openlibraryKey: str = ''
wikipediaLink: str = ''

View file

@ -1,16 +1,14 @@
''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod
from dataclasses import dataclass
import pytz
from urllib3.exceptions import RequestError
from django.db import transaction
from dateutil import parser
import requests
from requests import HTTPError
from requests.exceptions import SSLError
from bookwyrm import models
from bookwyrm import activitypub, models
class ConnectorException(HTTPError):
@ -38,7 +36,7 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None):
def search(self, query, min_confidence=None):# pylint: disable=unused-argument
''' free text search '''
resp = requests.get(
'%s%s' % (self.search_url, query),
@ -72,9 +70,6 @@ class AbstractConnector(AbstractMinimalConnector):
''' generic book data connector '''
def __init__(self, identifier):
super().__init__(identifier)
self.key_mappings = []
# fields we want to look for in book data to copy over
# title we handle separately.
self.book_mappings = []
@ -88,217 +83,110 @@ class AbstractConnector(AbstractMinimalConnector):
return True
@transaction.atomic
def get_or_create_book(self, remote_id):
# try to load the book
book = models.Book.objects.select_subclasses().filter(
origin_id=remote_id
).first()
if book:
if isinstance(book, models.Work):
return book.default_edition
return book
''' translate arbitrary json into an Activitypub dataclass '''
# first, check if we have the origin_id saved
existing = models.Edition.find_existing_by_remote_id(remote_id) or \
models.Work.find_existing_by_remote_id(remote_id)
if existing:
if hasattr(existing, 'get_default_editon'):
return existing.get_default_editon()
return existing
# no book was found, so we start creating a new one
# load the json
data = get_data(remote_id)
work = None
edition = None
mapped_data = dict_from_mappings(data, self.book_mappings)
if self.is_work_data(data):
work_data = data
# if we requested a work and there's already an edition, we're set
work = self.match_from_mappings(work_data, models.Work)
if work and work.default_edition:
return work.default_edition
# no such luck, we need more information.
try:
edition_data = self.get_edition_from_work_data(work_data)
edition_data = self.get_edition_from_work_data(data)
except KeyError:
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
work_data = mapped_data
else:
edition_data = data
edition = self.match_from_mappings(edition_data, models.Edition)
# no need to figure out about the work if we already know about it
if edition and edition.parent_work:
return edition
# no such luck, we need more information.
try:
work_data = self.get_work_from_edition_date(edition_data)
work_data = self.get_work_from_edition_data(data)
work_data = dict_from_mappings(work_data, self.book_mappings)
except KeyError:
# remember this hack: re-use the work data as the edition data
work_data = data
work_data = mapped_data
edition_data = data
if not work_data or not edition_data:
raise ConnectorException('Unable to load book data: %s' % remote_id)
# at this point, we need to figure out the work, edition, or both
# atomic so that we don't save a work with no edition for vice versa
with transaction.atomic():
if not work:
work_key = self.get_remote_id_from_data(work_data)
work = self.create_book(work_key, work_data, models.Work)
# create activitypub object
work_activity = activitypub.Work(**work_data)
# this will dedupe automatically
work = work_activity.to_model(models.Work)
for author in self.get_authors_from_data(data):
work.authors.add(author)
return self.create_edition_from_data(work, edition_data)
if not edition:
ed_key = self.get_remote_id_from_data(edition_data)
edition = self.create_book(ed_key, edition_data, models.Edition)
edition.parent_work = work
edition.save()
work.default_edition = edition
work.save()
# now's our change to fill in author gaps
def create_edition_from_data(self, work, edition_data):
''' if we already have the work, we're ready '''
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data['work'] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
edition = edition_activity.to_model(models.Edition)
edition.connector = self.connector
edition.save()
work.default_edition = edition
work.save()
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all())
edition.author_text = work.author_text
edition.save()
if not edition:
raise ConnectorException('Unable to create book: %s' % remote_id)
edition.authors.add(work.authors.all())
return edition
def create_book(self, remote_id, data, model):
''' create a work or edition from data '''
book = model.objects.create(
origin_id=remote_id,
title=data['title'],
connector=self.connector,
)
return self.update_book_from_data(book, data)
def get_or_create_author(self, remote_id):
''' load that author '''
existing = models.Author.find_existing_by_remote_id(remote_id)
if existing:
return existing
data = get_data(remote_id)
def update_book_from_data(self, book, data, update_cover=True):
''' for creating a new book or syncing with data '''
book = update_from_mappings(book, data, self.book_mappings)
author_text = []
for author in self.get_authors_from_data(data):
book.authors.add(author)
author_text.append(author.name)
book.author_text = ', '.join(author_text)
book.save()
if not update_cover:
return book
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def update_book(self, book, data=None):
''' load new data '''
if not book.sync and not book.sync_cover:
return book
if not data:
key = getattr(book, self.key_name)
data = self.load_book_data(key)
if book.sync:
book = self.update_book_from_data(
book, data, update_cover=book.sync_cover)
else:
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def match_from_mappings(self, data, model):
''' try to find existing copies of this book using various keys '''
relevent_mappings = [m for m in self.key_mappings if \
not m.model or model == m.model]
for mapping in relevent_mappings:
# check if this field is present in the data
value = data.get(mapping.remote_field)
if not value:
continue
# extract the value in the right format
value = mapping.formatter(value)
# search our database for a matching book
kwargs = {mapping.local_field: value}
match = model.objects.filter(**kwargs).first()
if match:
return match
return None
@abstractmethod
def get_remote_id_from_data(self, data):
''' otherwise we won't properly set the remote_id in the db '''
mapped_data = dict_from_mappings(data, self.author_mappings)
activity = activitypub.Author(**mapped_data)
# this will dedupe
return activity.to_model(models.Author)
@abstractmethod
def is_work_data(self, data):
''' differentiate works and editions '''
@abstractmethod
def get_edition_from_work_data(self, data):
''' every work needs at least one edition '''
@abstractmethod
def get_work_from_edition_date(self, data):
''' every edition needs a work '''
@abstractmethod
def get_authors_from_data(self, data):
''' load author data '''
@abstractmethod
def get_cover_from_data(self, data):
''' load cover '''
@abstractmethod
def expand_book_data(self, book):
''' get more info on a book '''
def update_from_mappings(obj, data, mappings):
''' assign data to model with mappings '''
def dict_from_mappings(data, mappings):
''' create a dict in Activitypub format, using mappings supplies by
the subclass '''
result = {}
for mapping in mappings:
# check if this field is present in the data
value = data.get(mapping.remote_field)
if not value:
continue
# extract the value in the right format
try:
value = mapping.formatter(value)
except:
continue
# assign the formatted value to the model
obj.__setattr__(mapping.local_field, value)
return obj
def get_date(date_string):
''' helper function to try to interpret dates '''
if not date_string:
return None
try:
return pytz.utc.localize(parser.parse(date_string))
except ValueError:
pass
try:
return parser.parse(date_string)
except ValueError:
return None
result[mapping.local_field] = mapping.get_value(data)
return result
def get_data(url):
@ -349,11 +237,19 @@ class SearchResult:
class Mapping:
''' associate a local database field with a field in an external dataset '''
def __init__(
self, local_field, remote_field=None, formatter=None, model=None):
def __init__(self, local_field, remote_field=None, formatter=None):
noop = lambda x: x
self.local_field = local_field
self.remote_field = remote_field or local_field
self.formatter = formatter or noop
self.model = model
def get_value(self, data):
''' pull a field from incoming json and return the formatted version '''
value = data.get(self.remote_field)
if not value:
return None
try:
return self.formatter(value)
except:# pylint: disable=bare-except
return None

View file

@ -1,13 +1,9 @@
''' openlibrary data connector '''
import re
import requests
from django.core.files.base import ContentFile
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import ConnectorException
from .abstract_connector import get_date, get_data, update_from_mappings
from .abstract_connector import ConnectorException, get_data
from .openlibrary_languages import languages
@ -17,62 +13,57 @@ class Connector(AbstractConnector):
super().__init__(identifier)
get_first = lambda a: a[0]
self.key_mappings = [
Mapping('isbn_13', model=models.Edition, formatter=get_first),
Mapping('isbn_10', model=models.Edition, formatter=get_first),
Mapping('lccn', model=models.Work, formatter=get_first),
get_remote_id = lambda a: self.base_url + a
self.book_mappings = [
Mapping('title'),
Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping(
'oclc_number',
remote_field='oclc_numbers',
model=models.Edition,
formatter=get_first
),
Mapping(
'openlibrary_key',
remote_field='key',
formatter=get_openlibrary_key
),
Mapping('goodreads_key'),
Mapping('asin'),
]
self.book_mappings = self.key_mappings + [
Mapping('sort_title'),
'cover', remote_field='covers', formatter=self.get_cover_url),
Mapping('sortTitle', remote_field='sort_title'),
Mapping('subtitle'),
Mapping('description', formatter=get_description),
Mapping('languages', formatter=get_languages),
Mapping('series', formatter=get_first),
Mapping('series_number'),
Mapping('seriesNumber', remote_field='series_number'),
Mapping('subjects'),
Mapping('subject_places'),
Mapping('subjectPlaces'),
Mapping('isbn13', formatter=get_first),
Mapping('isbn10', formatter=get_first),
Mapping('lccn', formatter=get_first),
Mapping(
'first_published_date',
remote_field='first_publish_date',
formatter=get_date
'oclcNumber', remote_field='oclc_numbers',
formatter=get_first
),
Mapping(
'published_date',
remote_field='publish_date',
formatter=get_date
'openlibraryKey', remote_field='key',
formatter=get_openlibrary_key
),
Mapping('goodreadsKey', remote_field='goodreads_key'),
Mapping('asin'),
Mapping(
'pages',
model=models.Edition,
remote_field='number_of_pages'
'firstPublishedDate', remote_field='first_publish_date',
),
Mapping('physical_format', model=models.Edition),
Mapping('publishedDate', remote_field='publish_date'),
Mapping('pages', remote_field='number_of_pages'),
Mapping('physicalFormat', remote_field='physical_format'),
Mapping('publishers'),
]
self.author_mappings = [
Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping('name'),
Mapping('born', remote_field='birth_date', formatter=get_date),
Mapping('died', remote_field='death_date', formatter=get_date),
Mapping(
'openlibraryKey', remote_field='key',
formatter=get_openlibrary_key
),
Mapping('born', remote_field='birth_date'),
Mapping('died', remote_field='death_date'),
Mapping('bio', formatter=get_description),
]
def get_remote_id_from_data(self, data):
''' format a url from an openlibrary id field '''
try:
key = data['key']
except KeyError:
@ -107,24 +98,17 @@ class Connector(AbstractConnector):
''' parse author json and load or create authors '''
for author_blob in data.get('authors', []):
author_blob = author_blob.get('author', author_blob)
# this id is "/authors/OL1234567A" and we want just "OL1234567A"
author_id = author_blob['key'].split('/')[-1]
yield self.get_or_create_author(author_id)
# this id is "/authors/OL1234567A"
author_id = author_blob['key']
url = '%s/%s.json' % (self.base_url, author_id)
yield self.get_or_create_author(url)
def get_cover_from_data(self, data):
def get_cover_url(self, cover_blob):
''' ask openlibrary for the cover '''
if not data.get('covers'):
return None
cover_id = data.get('covers')[0]
cover_id = cover_blob[0]
image_name = '%s-M.jpg' % cover_id
url = '%s/b/id/%s' % (self.covers_url, image_name)
response = requests.get(url)
if not response.ok:
response.raise_for_status()
image_content = ContentFile(response.content)
return [image_name, image_content]
return '%s/b/id/%s' % (self.covers_url, image_name)
def parse_search_data(self, data):
@ -158,37 +142,7 @@ class Connector(AbstractConnector):
# we can mass download edition data from OL to avoid repeatedly querying
edition_options = self.load_edition_data(work.openlibrary_key)
for edition_data in edition_options.get('entries'):
olkey = edition_data.get('key').split('/')[-1]
# make sure the edition isn't already in the database
if models.Edition.objects.filter(openlibrary_key=olkey).count():
continue
# creates and populates the book from the data
edition = self.create_book(olkey, edition_data, models.Edition)
# ensures that the edition is associated with the work
edition.parent_work = work
edition.save()
# get author data from the work if it's missing from the edition
if not edition.authors and work.authors:
edition.authors.set(work.authors.all())
def get_or_create_author(self, olkey):
''' load that author '''
if not re.match(r'^OL\d+A$', olkey):
raise ValueError('Invalid OpenLibrary author ID')
author = models.Author.objects.filter(openlibrary_key=olkey).first()
if author:
return author
url = '%s/authors/%s.json' % (self.base_url, olkey)
data = get_data(url)
author = models.Author(openlibrary_key=olkey)
author = update_from_mappings(author, data, self.author_mappings)
author.save()
return author
self.create_edition_from_data(work, edition_data)
def get_description(description_blob):

View file

@ -197,26 +197,11 @@ def handle_create(activity):
# not a type of status we are prepared to deserialize
return
if activity.type == 'Note':
# keep notes if they are replies to existing statuses
reply = models.Status.objects.filter(
remote_id=activity.inReplyTo
).first()
if not reply:
discard = True
# keep notes if they mention local users
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
for tag in tags:
if models.User.objects.filter(
remote_id=tag, local=True).exists():
# we found a mention of a known use boost
discard = False
break
if discard:
return
status = activity.to_model(model)
if not status:
# it was discarded because it's not a bookwyrm type
return
# create a notification if this is a reply
notified = []
if status.reply_parent and status.reply_parent.user.local:

View file

@ -73,7 +73,10 @@ class Book(ActivitypubMixin, BookWyrmModel):
@property
def alt_text(self):
''' image alt test '''
return '%s cover (%s)' % (self.title, self.edition_info)
text = '%s cover' % self.title
if self.edition_info:
text += ' (%s)' % self.edition_info
return text
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''

View file

@ -11,6 +11,7 @@ from django.core.files.base import ContentFile
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from markdown import markdown
from bookwyrm import activitypub
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
@ -25,6 +26,7 @@ def validate_remote_id(value):
params={'value': value},
)
def validate_username(value):
''' make sure usernames look okay '''
if not re.match(r'^[A-Za-z\-_\.]+$', value):
@ -71,6 +73,9 @@ class ActivitypubFieldMixin:
return
key = self.get_activitypub_field()
# TODO: surely there's a better way
if instance.__class__.__name__ == 'Boost' and key == 'attributedTo':
key = 'actor'
if isinstance(activity.get(key), list):
activity[key] += formatted
else:
@ -396,6 +401,16 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
sanitizer.feed(value)
return sanitizer.get_output()
def to_python(self, value):# pylint: disable=no-self-use
''' process markdown before save '''
if not value:
return value
content = markdown(value)
# sanitize resulting html
sanitizer = InputHtmlParser()
sanitizer.feed(content)
return sanitizer.get_output()
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
''' activitypub-aware array field '''
def field_to_activity(self, value):

View file

@ -1,7 +1,9 @@
''' models for storing different kinds of Activities '''
from django.utils import timezone
from dataclasses import MISSING
from django.apps import apps
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils import timezone
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
@ -46,6 +48,27 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
serialize_reverse_fields = [('attachments', 'attachment')]
deserialize_reverse_fields = [('attachments', 'attachment')]
@classmethod
def ignore_activity(cls, activity):
''' keep notes if they are replies to existing statuses '''
if activity.type != 'Note':
return False
if cls.objects.filter(
remote_id=activity.inReplyTo).exists():
return False
# keep notes if they mention local users
if activity.tag == MISSING or activity.tag is None:
return True
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
for tag in tags:
user_model = apps.get_model('bookwyrm.User', require_ready=True)
if user_model.objects.filter(
remote_id=tag, local=True).exists():
# we found a mention of a known use boost
return False
return True
@classmethod
def replies(cls, status):
''' load all replies to a status. idk if there's a better way
@ -59,6 +82,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' expose the type of status for the ui using activity type '''
return self.activity_serializer.__name__
@property
def boostable(self):
''' you can't boost dms '''
return self.privacy in ['unlisted', 'public']
def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection(
@ -127,8 +155,8 @@ class Comment(Status):
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(self.book.remote_id, self.book.title)
return '<p>%s</p><p>(comment on <a href="%s">"%s"</a>)</p>' % \
(self.content, self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment
pure_type = 'Note'
@ -143,7 +171,7 @@ class Quotation(Status):
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % (
return '<p>"%s"<br>-- <a href="%s">"%s"</a></p><p>%s</p>' % (
self.quote,
self.book.remote_id,
self.book.title,
@ -184,8 +212,7 @@ class Review(Status):
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.remote_id, self.book.title)
return self.content
activity_serializer = activitypub.Review
pure_type = 'Article'

View file

@ -91,6 +91,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = fields.BooleanField(default=False)
name_field = 'username'
@property
def alt_text(self):
''' alt text with username '''

View file

@ -211,18 +211,16 @@ 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 hen you click "remove"
# the cw text field remains populated when you click "remove"
status.content_warning = None
status.save()
# inspect the text for user tags
text = status.content
matches = re.finditer(
regex.username,
text
)
for match in matches:
matches = []
for match in re.finditer(regex.username, status.content):
username = match.group().strip().split('@')[1:]
print(match.group())
print(len(username))
if len(username) == 1:
# this looks like a local user (@user), fill in the domain
username.append(DOMAIN)
@ -232,6 +230,7 @@ def handle_status(user, form):
if not mention_user:
# we can ignore users we don't know about
continue
matches.append((match.group(), mention_user.remote_id))
# add them to status mentions fk
status.mention_users.add(mention_user)
# create notification if the mentioned user is local
@ -242,6 +241,20 @@ def handle_status(user, form):
related_user=user,
related_status=status
)
# add links
content = status.content
content = re.sub(
r'([^(href=")])(https?:\/\/([A-Za-z\.\-_\/]+' \
r'\.[A-Za-z]{2,}[A-Za-z\.\-_\/]+))',
r'\g<1><a href="\g<2>">\g<3></a>',
content)
for (username, url) in matches:
content = re.sub(
r'%s([^@])' % username,
r'<a href="%s">%s</a>\g<1>' % (url, username),
content)
status.content = content
status.save()
# notify reply parent or tagged users
@ -315,15 +328,19 @@ def handle_unfavorite(user, status):
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.save()
boost_activity = boost.to_activity()
broadcast(user, boost_activity)

View file

@ -6,7 +6,11 @@ class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method
def __init__(self):
HTMLParser.__init__(self)
self.allowed_tags = ['p', 'b', 'i', 'pre', 'a', 'span']
self.allowed_tags = [
'p', 'br',
'b', 'i', 'strong', 'em', 'pre',
'a', 'span', 'ul', 'ol', 'li'
]
self.tag_stack = []
self.output = []
# if the html appears invalid, we just won't allow any at all

View file

@ -137,8 +137,3 @@ input.toggle-control:checked ~ .modal.toggle-content {
content: "\e904";
right: 0;
}
/* --- BLOCKQUOTE --- */
blockquote {
white-space: pre-line;
}

View file

@ -103,14 +103,14 @@
<div>
{% for shelf in user_shelves %}
<p>
This edition is on your <a href="/user/{{ user.localname }}/shelves/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
This edition is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
</p>
{% endfor %}
{% for shelf in other_edition_shelves %}
<p>
A <a href="/book/{{ shelf.book.id }}">different edition</a> of this book is on your <a href="/user/{{ user.localname }}/shelves/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
A <a href="/book/{{ shelf.book.id }}">different edition</a> of this book is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
{% include 'snippets/switch_edition_button.html' with edition=book %}
</p>
{% endfor %}

View file

@ -1,8 +1,9 @@
{% load bookwyrm_tags %}
{% with status.id|uuid as uuid %}
<form name="boost" action="/boost/{{ status.id }}" method="post" onsubmit="return interact(event)" class="boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
{% csrf_token %}
<button class="button is-small" type="submit">
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
<span class="icon icon-boost">
<span class="is-sr-only">Boost status</span>
</span>

View file

@ -27,7 +27,7 @@
{% if status.quote %}
<div class="quote block">
<blockquote>{{ status.quote }}</blockquote>
<blockquote>{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>

View file

@ -30,10 +30,10 @@ class AbstractConnector(TestCase):
'series': ['one', 'two'],
}
self.connector.key_mappings = [
Mapping('isbn_10', model=models.Edition),
Mapping('isbn_10'),
Mapping('isbn_13'),
Mapping('lccn', model=models.Work),
Mapping('asin', remote_field='ASIN'),
Mapping('lccn'),
Mapping('asin'),
]
@ -41,7 +41,6 @@ class AbstractConnector(TestCase):
mapping = Mapping('isbn')
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.model, None)
self.assertEqual(mapping.formatter('bb'), 'bb')
@ -49,7 +48,6 @@ class AbstractConnector(TestCase):
mapping = Mapping('isbn', remote_field='isbn13')
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn13')
self.assertEqual(mapping.model, None)
self.assertEqual(mapping.formatter('bb'), 'bb')
@ -59,40 +57,4 @@ class AbstractConnector(TestCase):
self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.formatter, formatter)
self.assertEqual(mapping.model, None)
self.assertEqual(mapping.formatter('bb'), 'aabb')
def test_match_from_mappings(self):
edition = models.Edition.objects.create(
title='Blah',
isbn_13='blahhh',
)
match = self.connector.match_from_mappings(self.data, models.Edition)
self.assertEqual(match, edition)
def test_match_from_mappings_with_model(self):
edition = models.Edition.objects.create(
title='Blah',
isbn_10='1234567890',
)
match = self.connector.match_from_mappings(self.data, models.Edition)
self.assertEqual(match, edition)
def test_match_from_mappings_with_remote(self):
edition = models.Edition.objects.create(
title='Blah',
asin='A00BLAH',
)
match = self.connector.match_from_mappings(self.data, models.Edition)
self.assertEqual(match, edition)
def test_match_from_mappings_no_match(self):
edition = models.Edition.objects.create(
title='Blah',
)
match = self.connector.match_from_mappings(self.data, models.Edition)
self.assertEqual(match, None)

View file

@ -1,15 +1,16 @@
''' testing book data connectors '''
from dateutil import parser
from django.test import TestCase
import json
import pathlib
from dateutil import parser
from django.test import TestCase
import pytz
from bookwyrm import models
from bookwyrm.connectors.openlibrary import Connector
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, get_date
from bookwyrm.connectors.openlibrary import pick_default_edition, \
get_openlibrary_key
from bookwyrm.connectors.abstract_connector import SearchResult
class Openlibrary(TestCase):
@ -67,12 +68,6 @@ class Openlibrary(TestCase):
self.assertEqual(description, expected)
def test_get_date(self):
date = get_date(self.work_data['first_publish_date'])
expected = pytz.utc.localize(parser.parse('1995'))
self.assertEqual(date, expected)
def test_get_languages(self):
languages = get_languages(self.edition_data['languages'])
self.assertEqual(languages, ['English'])
@ -81,4 +76,3 @@ class Openlibrary(TestCase):
def test_get_ol_key(self):
key = get_openlibrary_key('/books/OL27320736M')
self.assertEqual(key, 'OL27320736M')

View file

@ -1,42 +1,275 @@
''' testing models '''
from io import BytesIO
import pathlib
from PIL import Image
from django.core.files.base import ContentFile
from django.db import IntegrityError
from django.test import TestCase
from django.utils import timezone
from bookwyrm import models, settings
class Status(TestCase):
''' lotta types of statuses '''
def setUp(self):
user = models.User.objects.create_user(
''' useful things for creating a status '''
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
book = models.Edition.objects.create(title='Example Edition')
self.book = models.Edition.objects.create(title='Test Edition')
models.Status.objects.create(user=user, content='Blah blah')
models.Comment.objects.create(user=user, content='content', book=book)
models.Quotation.objects.create(
user=user, content='content', book=book, quote='blah')
models.Review.objects.create(
user=user, content='content', book=book, rating=3)
image_file = pathlib.Path(__file__).parent.joinpath(
'../../static/images/default_avi.jpg')
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
self.book.cover.save(
'test.jpg',
ContentFile(output.getvalue())
)
def test_status(self):
status = models.Status.objects.first()
def test_status_generated_fields(self):
''' setting remote id '''
status = models.Status.objects.create(content='bleh', user=self.user)
expected_id = 'https://%s/user/mouse/status/%d' % \
(settings.DOMAIN, status.id)
self.assertEqual(status.remote_id, expected_id)
self.assertEqual(status.privacy, 'public')
def test_comment(self):
comment = models.Comment.objects.first()
expected_id = 'https://%s/user/mouse/comment/%d' % \
(settings.DOMAIN, comment.id)
self.assertEqual(comment.remote_id, expected_id)
def test_replies(self):
''' get a list of replies '''
parent = models.Status.objects.create(content='hi', user=self.user)
child = models.Status.objects.create(
content='hello', reply_parent=parent, user=self.user)
models.Review.objects.create(
content='hey', reply_parent=parent, user=self.user, book=self.book)
models.Status.objects.create(
content='hi hello', reply_parent=child, user=self.user)
def test_quotation(self):
quotation = models.Quotation.objects.first()
expected_id = 'https://%s/user/mouse/quotation/%d' % \
(settings.DOMAIN, quotation.id)
self.assertEqual(quotation.remote_id, expected_id)
replies = models.Status.replies(parent)
self.assertEqual(replies.count(), 2)
self.assertEqual(replies.first(), child)
# should select subclasses
self.assertIsInstance(replies.last(), models.Review)
def test_review(self):
review = models.Review.objects.first()
expected_id = 'https://%s/user/mouse/review/%d' % \
(settings.DOMAIN, review.id)
self.assertEqual(review.remote_id, expected_id)
def test_status_type(self):
''' class name '''
self.assertEqual(models.Status().status_type, 'Note')
self.assertEqual(models.Review().status_type, 'Review')
self.assertEqual(models.Quotation().status_type, 'Quotation')
self.assertEqual(models.Comment().status_type, 'Comment')
self.assertEqual(models.Boost().status_type, 'Boost')
def test_boostable(self):
''' can a status be boosted, based on privacy '''
self.assertTrue(models.Status(privacy='public').boostable)
self.assertTrue(models.Status(privacy='unlisted').boostable)
self.assertFalse(models.Status(privacy='followers').boostable)
self.assertFalse(models.Status(privacy='direct').boostable)
def test_to_replies(self):
''' activitypub replies collection '''
parent = models.Status.objects.create(content='hi', user=self.user)
child = models.Status.objects.create(
content='hello', reply_parent=parent, user=self.user)
models.Review.objects.create(
content='hey', reply_parent=parent, user=self.user, book=self.book)
models.Status.objects.create(
content='hi hello', reply_parent=child, user=self.user)
replies = parent.to_replies()
self.assertEqual(replies['id'], '%s/replies' % parent.remote_id)
self.assertEqual(replies['totalItems'], 2)
def test_status_to_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Status.objects.create(
content='test content', user=self.user)
activity = status.to_activity()
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Note')
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['sensitive'], False)
def test_status_to_activity_tombstone(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Status.objects.create(
content='test content', user=self.user,
deleted=True, deleted_date=timezone.now())
activity = status.to_activity()
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Tombstone')
self.assertFalse(hasattr(activity, 'content'))
def test_status_to_pure_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Status.objects.create(
content='test content', user=self.user)
activity = status.to_activity(pure=True)
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Note')
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['sensitive'], False)
self.assertEqual(activity['attachment'], [])
def test_generated_note_to_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.GeneratedNote.objects.create(
content='test content', user=self.user)
status.mention_books.set([self.book])
status.mention_users.set([self.user])
activity = status.to_activity()
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'GeneratedNote')
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['sensitive'], False)
self.assertEqual(len(activity['tag']), 2)
def test_generated_note_to_pure_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.GeneratedNote.objects.create(
content='test content', user=self.user)
status.mention_books.set([self.book])
status.mention_users.set([self.user])
activity = status.to_activity(pure=True)
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(
activity['content'],
'mouse test content <a href="%s">"Test Edition"</a>' % \
self.book.remote_id)
self.assertEqual(len(activity['tag']), 2)
self.assertEqual(activity['type'], 'Note')
self.assertEqual(activity['sensitive'], False)
self.assertIsInstance(activity['attachment'], list)
self.assertEqual(activity['attachment'][0].type, 'Image')
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
(settings.DOMAIN, self.book.cover.url))
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_comment_to_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Comment.objects.create(
content='test content', user=self.user, book=self.book)
activity = status.to_activity()
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Comment')
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
def test_comment_to_pure_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Comment.objects.create(
content='test content', user=self.user, book=self.book)
activity = status.to_activity(pure=True)
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Note')
self.assertEqual(
activity['content'],
'<p>test content</p><p>' \
'(comment on <a href="%s">"Test Edition"</a>)</p>' %
self.book.remote_id)
self.assertEqual(activity['attachment'][0].type, 'Image')
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
(settings.DOMAIN, self.book.cover.url))
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_quotation_to_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Quotation.objects.create(
quote='a sickening sense', content='test content',
user=self.user, book=self.book)
activity = status.to_activity()
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Quotation')
self.assertEqual(activity['quote'], 'a sickening sense')
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
def test_quotation_to_pure_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Quotation.objects.create(
quote='a sickening sense', content='test content',
user=self.user, book=self.book)
activity = status.to_activity(pure=True)
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Note')
self.assertEqual(
activity['content'],
'<p>"a sickening sense"<br>-- <a href="%s">"Test Edition"</a></p>' \
'<p>test content</p>' % self.book.remote_id)
self.assertEqual(activity['attachment'][0].type, 'Image')
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
(settings.DOMAIN, self.book.cover.url))
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_review_to_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Review.objects.create(
name='Review name', content='test content', rating=3,
user=self.user, book=self.book)
activity = status.to_activity()
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Review')
self.assertEqual(activity['rating'], 3)
self.assertEqual(activity['name'], 'Review name')
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['inReplyToBook'], self.book.remote_id)
def test_review_to_pure_activity(self):
''' subclass of the base model version with a "pure" serializer '''
status = models.Review.objects.create(
name='Review name', content='test content', rating=3,
user=self.user, book=self.book)
activity = status.to_activity(pure=True)
self.assertEqual(activity['id'], status.remote_id)
self.assertEqual(activity['type'], 'Article')
self.assertEqual(
activity['name'], 'Review of "%s" (3 stars): Review name' \
% self.book.title)
self.assertEqual(activity['content'], 'test content')
self.assertEqual(activity['attachment'][0].type, 'Image')
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
(settings.DOMAIN, self.book.cover.url))
self.assertEqual(
activity['attachment'][0].name, 'Test Edition cover')
def test_favorite(self):
''' fav a status '''
status = models.Status.objects.create(
content='test content', user=self.user)
fav = models.Favorite.objects.create(status=status, user=self.user)
# can't fav a status twice
with self.assertRaises(IntegrityError):
models.Favorite.objects.create(status=status, user=self.user)
activity = fav.to_activity()
self.assertEqual(activity['type'], 'Like')
self.assertEqual(activity['actor'], self.user.remote_id)
self.assertEqual(activity['object'], status.remote_id)
def test_boost(self):
''' boosting, this one's a bit fussy '''
status = models.Status.objects.create(
content='test content', user=self.user)
boost = models.Boost.objects.create(
boosted_status=status, user=self.user)
activity = boost.to_activity()
self.assertEqual(activity['actor'], self.user.remote_id)
self.assertEqual(activity['object'], status.remote_id)
self.assertEqual(activity['type'], 'Announce')
self.assertEqual(activity, boost.to_activity(pure=True))
def test_notification(self):
''' a simple model '''
notification = models.Notification.objects.create(
user=self.user, notification_type='FAVORITE')
self.assertFalse(notification.read)
with self.assertRaises(IntegrityError):
models.Notification.objects.create(
user=self.user, notification_type='GLORB')

View file

@ -262,8 +262,8 @@ class Incoming(TestCase):
status = models.Quotation.objects.get()
self.assertEqual(
status.remote_id, 'https://example.com/user/mouse/quotation/13')
self.assertEqual(status.quote, 'quote body')
self.assertEqual(status.content, 'commentary')
self.assertEqual(status.quote, '<p>quote body</p>')
self.assertEqual(status.content, '<p>commentary</p>')
self.assertEqual(status.user, self.local_user)
self.assertEqual(models.Status.objects.count(), 2)
@ -284,7 +284,7 @@ class Incoming(TestCase):
incoming.handle_create(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.content, '<p>test content in note</p>')
self.assertEqual(status.mention_users.first(), self.local_user)
self.assertTrue(
models.Notification.objects.filter(user=self.local_user).exists())
@ -306,7 +306,7 @@ class Incoming(TestCase):
incoming.handle_create(activity)
status = models.Status.objects.last()
self.assertEqual(status.content, 'test content in note')
self.assertEqual(status.content, '<p>test content in note</p>')
self.assertEqual(status.reply_parent, self.status)
self.assertTrue(
models.Notification.objects.filter(user=self.local_user))
@ -410,7 +410,10 @@ class Incoming(TestCase):
'actor': self.remote_user.remote_id,
'object': self.status.to_activity(),
}
incoming.handle_boost(activity)
with patch('bookwyrm.models.status.Status.ignore_activity') \
as discarder:
discarder.return_value = False
incoming.handle_boost(activity)
boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, self.status)
notification = models.Notification.objects.get()

View file

@ -223,6 +223,8 @@ def resolve_book(request):
remote_id = request.POST.get('remote_id')
connector = books_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(remote_id)
if book.connector:
books_manager.load_more_data.delay(book.id)
return redirect('/book/%d' % book.id)

View file

@ -615,18 +615,13 @@ def book_page(request, book_id):
book__parent_work=book.parent_work,
)
rating = reviews.aggregate(Avg('rating'))
tags = models.UserTag.objects.filter(
book=book,
)
data = {
'title': book.title,
'book': book,
'reviews': reviews_page,
'ratings': reviews.filter(content__isnull=True),
'rating': rating['rating__avg'],
'tags': tags,
'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,
@ -761,7 +756,7 @@ def shelf_page(request, username, shelf_identifier):
return JsonResponse(shelf.to_activity(**request.GET))
data = {
'title': user.name,
'title': '%s\'s %s shelf' % (user.display_name, shelf.name),
'user': user,
'is_self': is_self,
'shelves': shelves.all(),

View file

@ -4,6 +4,7 @@ Django==3.0.7
django-model-utils==4.0.0
environs==7.2.0
flower==0.9.4
Markdown==3.3.3
Pillow>=7.1.0
psycopg2==2.8.4
pycryptodome==3.9.4