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) 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 # check for an existing instance, if we're not updating a known obj
instance = instance or model.find_existing(self.serialize()) or model() 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 ''' ''' work instance of a book object '''
lccn: str = '' lccn: str = ''
defaultEdition: str = '' defaultEdition: str = ''
editions: List[str] editions: List[str] = field(default_factory=lambda: [])
type: str = 'Work' type: str = 'Work'
@ -58,9 +58,9 @@ class Work(Book):
class Author(ActivityObject): class Author(ActivityObject):
''' author of a book ''' ''' author of a book '''
name: str name: str
born: str = '' born: str = None
died: str = '' died: str = None
aliases: str = '' aliases: List[str] = field(default_factory=lambda: [])
bio: str = '' bio: str = ''
openlibraryKey: str = '' openlibraryKey: str = ''
wikipediaLink: str = '' wikipediaLink: str = ''

View file

@ -1,16 +1,14 @@
''' functionality outline for a book data connector ''' ''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass from dataclasses import dataclass
import pytz
from urllib3.exceptions import RequestError from urllib3.exceptions import RequestError
from django.db import transaction from django.db import transaction
from dateutil import parser
import requests import requests
from requests import HTTPError from requests import HTTPError
from requests.exceptions import SSLError from requests.exceptions import SSLError
from bookwyrm import models from bookwyrm import activitypub, models
class ConnectorException(HTTPError): class ConnectorException(HTTPError):
@ -38,7 +36,7 @@ class AbstractMinimalConnector(ABC):
for field in self_fields: for field in self_fields:
setattr(self, field, getattr(info, field)) 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 ''' ''' free text search '''
resp = requests.get( resp = requests.get(
'%s%s' % (self.search_url, query), '%s%s' % (self.search_url, query),
@ -72,9 +70,6 @@ class AbstractConnector(AbstractMinimalConnector):
''' generic book data connector ''' ''' generic book data connector '''
def __init__(self, identifier): def __init__(self, identifier):
super().__init__(identifier) super().__init__(identifier)
self.key_mappings = []
# fields we want to look for in book data to copy over # fields we want to look for in book data to copy over
# title we handle separately. # title we handle separately.
self.book_mappings = [] self.book_mappings = []
@ -88,217 +83,110 @@ class AbstractConnector(AbstractMinimalConnector):
return True return True
@transaction.atomic
def get_or_create_book(self, remote_id): def get_or_create_book(self, remote_id):
# try to load the book ''' translate arbitrary json into an Activitypub dataclass '''
book = models.Book.objects.select_subclasses().filter( # first, check if we have the origin_id saved
origin_id=remote_id existing = models.Edition.find_existing_by_remote_id(remote_id) or \
).first() models.Work.find_existing_by_remote_id(remote_id)
if book: if existing:
if isinstance(book, models.Work): if hasattr(existing, 'get_default_editon'):
return book.default_edition return existing.get_default_editon()
return book return existing
# no book was found, so we start creating a new one # load the json
data = get_data(remote_id) data = get_data(remote_id)
mapped_data = dict_from_mappings(data, self.book_mappings)
work = None
edition = None
if self.is_work_data(data): 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: try:
edition_data = self.get_edition_from_work_data(work_data) edition_data = self.get_edition_from_work_data(data)
except KeyError: except KeyError:
# hack: re-use the work data as the edition data # hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique # this is why remote ids aren't necessarily unique
edition_data = data edition_data = data
work_data = mapped_data
else: 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: 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: except KeyError:
# remember this hack: re-use the work data as the edition data work_data = mapped_data
work_data = data edition_data = data
if not work_data or not edition_data: if not work_data or not edition_data:
raise ConnectorException('Unable to load book data: %s' % remote_id) raise ConnectorException('Unable to load book data: %s' % remote_id)
# at this point, we need to figure out the work, edition, or both # create activitypub object
# atomic so that we don't save a work with no edition for vice versa work_activity = activitypub.Work(**work_data)
with transaction.atomic(): # this will dedupe automatically
if not work: work = work_activity.to_model(models.Work)
work_key = self.get_remote_id_from_data(work_data) for author in self.get_authors_from_data(data):
work = self.create_book(work_key, work_data, models.Work) 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(): if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all()) edition.authors.add(work.authors.all())
edition.author_text = work.author_text
edition.save()
if not edition:
raise ConnectorException('Unable to create book: %s' % remote_id)
return edition return edition
def create_book(self, remote_id, data, model): def get_or_create_author(self, remote_id):
''' create a work or edition from data ''' ''' load that author '''
book = model.objects.create( existing = models.Author.find_existing_by_remote_id(remote_id)
origin_id=remote_id, if existing:
title=data['title'], return existing
connector=self.connector,
)
return self.update_book_from_data(book, data)
data = get_data(remote_id)
def update_book_from_data(self, book, data, update_cover=True): mapped_data = dict_from_mappings(data, self.author_mappings)
''' for creating a new book or syncing with data ''' activity = activitypub.Author(**mapped_data)
book = update_from_mappings(book, data, self.book_mappings) # this will dedupe
return activity.to_model(models.Author)
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 '''
@abstractmethod @abstractmethod
def is_work_data(self, data): def is_work_data(self, data):
''' differentiate works and editions ''' ''' differentiate works and editions '''
@abstractmethod @abstractmethod
def get_edition_from_work_data(self, data): def get_edition_from_work_data(self, data):
''' every work needs at least one edition ''' ''' every work needs at least one edition '''
@abstractmethod @abstractmethod
def get_work_from_edition_date(self, data): def get_work_from_edition_date(self, data):
''' every edition needs a work ''' ''' every edition needs a work '''
@abstractmethod @abstractmethod
def get_authors_from_data(self, data): def get_authors_from_data(self, data):
''' load author data ''' ''' load author data '''
@abstractmethod
def get_cover_from_data(self, data):
''' load cover '''
@abstractmethod @abstractmethod
def expand_book_data(self, book): def expand_book_data(self, book):
''' get more info on a book ''' ''' get more info on a book '''
def update_from_mappings(obj, data, mappings): def dict_from_mappings(data, mappings):
''' assign data to model with mappings ''' ''' create a dict in Activitypub format, using mappings supplies by
the subclass '''
result = {}
for mapping in mappings: for mapping in mappings:
# check if this field is present in the data result[mapping.local_field] = mapping.get_value(data)
value = data.get(mapping.remote_field) return result
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
def get_data(url): def get_data(url):
@ -349,11 +237,19 @@ class SearchResult:
class Mapping: class Mapping:
''' associate a local database field with a field in an external dataset ''' ''' associate a local database field with a field in an external dataset '''
def __init__( def __init__(self, local_field, remote_field=None, formatter=None):
self, local_field, remote_field=None, formatter=None, model=None):
noop = lambda x: x noop = lambda x: x
self.local_field = local_field self.local_field = local_field
self.remote_field = remote_field or local_field self.remote_field = remote_field or local_field
self.formatter = formatter or noop 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 ''' ''' openlibrary data connector '''
import re import re
import requests
from django.core.files.base import ContentFile
from bookwyrm import models from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import ConnectorException from .abstract_connector import ConnectorException, get_data
from .abstract_connector import get_date, get_data, update_from_mappings
from .openlibrary_languages import languages from .openlibrary_languages import languages
@ -17,62 +13,57 @@ class Connector(AbstractConnector):
super().__init__(identifier) super().__init__(identifier)
get_first = lambda a: a[0] get_first = lambda a: a[0]
self.key_mappings = [ get_remote_id = lambda a: self.base_url + a
Mapping('isbn_13', model=models.Edition, formatter=get_first), self.book_mappings = [
Mapping('isbn_10', model=models.Edition, formatter=get_first), Mapping('title'),
Mapping('lccn', model=models.Work, formatter=get_first), Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping( Mapping(
'oclc_number', 'cover', remote_field='covers', formatter=self.get_cover_url),
remote_field='oclc_numbers', Mapping('sortTitle', remote_field='sort_title'),
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'),
Mapping('subtitle'), Mapping('subtitle'),
Mapping('description', formatter=get_description), Mapping('description', formatter=get_description),
Mapping('languages', formatter=get_languages), Mapping('languages', formatter=get_languages),
Mapping('series', formatter=get_first), Mapping('series', formatter=get_first),
Mapping('series_number'), Mapping('seriesNumber', remote_field='series_number'),
Mapping('subjects'), Mapping('subjects'),
Mapping('subject_places'), Mapping('subjectPlaces'),
Mapping('isbn13', formatter=get_first),
Mapping('isbn10', formatter=get_first),
Mapping('lccn', formatter=get_first),
Mapping( Mapping(
'first_published_date', 'oclcNumber', remote_field='oclc_numbers',
remote_field='first_publish_date', formatter=get_first
formatter=get_date
), ),
Mapping( Mapping(
'published_date', 'openlibraryKey', remote_field='key',
remote_field='publish_date', formatter=get_openlibrary_key
formatter=get_date
), ),
Mapping('goodreadsKey', remote_field='goodreads_key'),
Mapping('asin'),
Mapping( Mapping(
'pages', 'firstPublishedDate', remote_field='first_publish_date',
model=models.Edition,
remote_field='number_of_pages'
), ),
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'), Mapping('publishers'),
] ]
self.author_mappings = [ self.author_mappings = [
Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping('name'), Mapping('name'),
Mapping('born', remote_field='birth_date', formatter=get_date), Mapping(
Mapping('died', remote_field='death_date', formatter=get_date), '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), Mapping('bio', formatter=get_description),
] ]
def get_remote_id_from_data(self, data): def get_remote_id_from_data(self, data):
''' format a url from an openlibrary id field '''
try: try:
key = data['key'] key = data['key']
except KeyError: except KeyError:
@ -107,24 +98,17 @@ class Connector(AbstractConnector):
''' parse author json and load or create authors ''' ''' parse author json and load or create authors '''
for author_blob in data.get('authors', []): for author_blob in data.get('authors', []):
author_blob = author_blob.get('author', author_blob) author_blob = author_blob.get('author', author_blob)
# this id is "/authors/OL1234567A" and we want just "OL1234567A" # this id is "/authors/OL1234567A"
author_id = author_blob['key'].split('/')[-1] author_id = author_blob['key']
yield self.get_or_create_author(author_id) 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 ''' ''' ask openlibrary for the cover '''
if not data.get('covers'): cover_id = cover_blob[0]
return None
cover_id = data.get('covers')[0]
image_name = '%s-M.jpg' % cover_id image_name = '%s-M.jpg' % cover_id
url = '%s/b/id/%s' % (self.covers_url, image_name) return '%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]
def parse_search_data(self, data): 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 # we can mass download edition data from OL to avoid repeatedly querying
edition_options = self.load_edition_data(work.openlibrary_key) edition_options = self.load_edition_data(work.openlibrary_key)
for edition_data in edition_options.get('entries'): for edition_data in edition_options.get('entries'):
olkey = edition_data.get('key').split('/')[-1] self.create_edition_from_data(work, edition_data)
# 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
def get_description(description_blob): 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 # not a type of status we are prepared to deserialize
return 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) 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 # create a notification if this is a reply
notified = [] notified = []
if status.reply_parent and status.reply_parent.user.local: if status.reply_parent and status.reply_parent.user.local:

View file

@ -73,7 +73,10 @@ class Book(ActivitypubMixin, BookWyrmModel):
@property @property
def alt_text(self): def alt_text(self):
''' image alt test ''' ''' 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): def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it ''' ''' 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.db import models
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from markdown import markdown
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
@ -25,6 +26,7 @@ def validate_remote_id(value):
params={'value': value}, params={'value': value},
) )
def validate_username(value): def validate_username(value):
''' make sure usernames look okay ''' ''' make sure usernames look okay '''
if not re.match(r'^[A-Za-z\-_\.]+$', value): if not re.match(r'^[A-Za-z\-_\.]+$', value):
@ -71,6 +73,9 @@ class ActivitypubFieldMixin:
return return
key = self.get_activitypub_field() 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): if isinstance(activity.get(key), list):
activity[key] += formatted activity[key] += formatted
else: else:
@ -396,6 +401,16 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
sanitizer.feed(value) sanitizer.feed(value)
return sanitizer.get_output() 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): class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
''' activitypub-aware array field ''' ''' activitypub-aware array field '''
def field_to_activity(self, value): def field_to_activity(self, value):

View file

@ -1,7 +1,9 @@
''' models for storing different kinds of Activities ''' ''' 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.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils import timezone
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
@ -46,6 +48,27 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
serialize_reverse_fields = [('attachments', 'attachment')] serialize_reverse_fields = [('attachments', 'attachment')]
deserialize_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 @classmethod
def replies(cls, status): def replies(cls, status):
''' load all replies to a status. idk if there's a better way ''' 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 ''' ''' expose the type of status for the ui using activity type '''
return self.activity_serializer.__name__ 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): def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status ''' ''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection( return self.to_ordered_collection(
@ -127,8 +155,8 @@ class Comment(Status):
@property @property
def pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \ return '<p>%s</p><p>(comment on <a href="%s">"%s"</a>)</p>' % \
(self.book.remote_id, self.book.title) (self.content, self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment activity_serializer = activitypub.Comment
pure_type = 'Note' pure_type = 'Note'
@ -143,7 +171,7 @@ class Quotation(Status):
@property @property
def pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' 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.quote,
self.book.remote_id, self.book.remote_id,
self.book.title, self.book.title,
@ -184,8 +212,7 @@ class Review(Status):
@property @property
def pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \ return self.content
(self.book.remote_id, self.book.title)
activity_serializer = activitypub.Review activity_serializer = activitypub.Review
pure_type = 'Article' pure_type = 'Article'

View file

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

View file

@ -211,18 +211,16 @@ def handle_status(user, form):
''' generic handler for statuses ''' ''' generic handler for statuses '''
status = form.save(commit=False) status = form.save(commit=False)
if not status.sensitive and status.content_warning: 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.content_warning = None
status.save() status.save()
# inspect the text for user tags # inspect the text for user tags
text = status.content matches = []
matches = re.finditer( for match in re.finditer(regex.username, status.content):
regex.username,
text
)
for match in matches:
username = match.group().strip().split('@')[1:] username = match.group().strip().split('@')[1:]
print(match.group())
print(len(username))
if len(username) == 1: if len(username) == 1:
# this looks like a local user (@user), fill in the domain # this looks like a local user (@user), fill in the domain
username.append(DOMAIN) username.append(DOMAIN)
@ -232,6 +230,7 @@ def handle_status(user, form):
if not mention_user: if not mention_user:
# we can ignore users we don't know about # we can ignore users we don't know about
continue continue
matches.append((match.group(), mention_user.remote_id))
# add them to status mentions fk # add them to status mentions fk
status.mention_users.add(mention_user) status.mention_users.add(mention_user)
# create notification if the mentioned user is local # create notification if the mentioned user is local
@ -242,6 +241,20 @@ def handle_status(user, form):
related_user=user, related_user=user,
related_status=status 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() status.save()
# notify reply parent or tagged users # notify reply parent or tagged users
@ -315,15 +328,19 @@ def handle_unfavorite(user, status):
def handle_boost(user, status): def handle_boost(user, status):
''' a user wishes to boost a status ''' ''' a user wishes to boost a status '''
# is it boostable?
if not status.boostable:
return
if models.Boost.objects.filter( if models.Boost.objects.filter(
boosted_status=status, user=user).exists(): boosted_status=status, user=user).exists():
# you already boosted that. # you already boosted that.
return return
boost = models.Boost.objects.create( boost = models.Boost.objects.create(
boosted_status=status, boosted_status=status,
privacy=status.privacy,
user=user, user=user,
) )
boost.save()
boost_activity = boost.to_activity() boost_activity = boost.to_activity()
broadcast(user, boost_activity) broadcast(user, boost_activity)

View file

@ -6,7 +6,11 @@ class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method
def __init__(self): def __init__(self):
HTMLParser.__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.tag_stack = []
self.output = [] self.output = []
# if the html appears invalid, we just won't allow any at all # 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"; content: "\e904";
right: 0; right: 0;
} }
/* --- BLOCKQUOTE --- */
blockquote {
white-space: pre-line;
}

View file

@ -103,14 +103,14 @@
<div> <div>
{% for shelf in user_shelves %} {% for shelf in user_shelves %}
<p> <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 %} {% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
</p> </p>
{% endfor %} {% endfor %}
{% for shelf in other_edition_shelves %} {% for shelf in other_edition_shelves %}
<p> <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 %} {% include 'snippets/switch_edition_button.html' with edition=book %}
</p> </p>
{% endfor %} {% endfor %}

View file

@ -1,8 +1,9 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% with status.id|uuid as uuid %} {% 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 }}"> <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 %} {% 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="icon icon-boost">
<span class="is-sr-only">Boost status</span> <span class="is-sr-only">Boost status</span>
</span> </span>

View file

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

View file

@ -30,10 +30,10 @@ class AbstractConnector(TestCase):
'series': ['one', 'two'], 'series': ['one', 'two'],
} }
self.connector.key_mappings = [ self.connector.key_mappings = [
Mapping('isbn_10', model=models.Edition), Mapping('isbn_10'),
Mapping('isbn_13'), Mapping('isbn_13'),
Mapping('lccn', model=models.Work), Mapping('lccn'),
Mapping('asin', remote_field='ASIN'), Mapping('asin'),
] ]
@ -41,7 +41,6 @@ class AbstractConnector(TestCase):
mapping = Mapping('isbn') mapping = Mapping('isbn')
self.assertEqual(mapping.local_field, 'isbn') self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn') self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.model, None)
self.assertEqual(mapping.formatter('bb'), 'bb') self.assertEqual(mapping.formatter('bb'), 'bb')
@ -49,7 +48,6 @@ class AbstractConnector(TestCase):
mapping = Mapping('isbn', remote_field='isbn13') mapping = Mapping('isbn', remote_field='isbn13')
self.assertEqual(mapping.local_field, 'isbn') self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn13') self.assertEqual(mapping.remote_field, 'isbn13')
self.assertEqual(mapping.model, None)
self.assertEqual(mapping.formatter('bb'), 'bb') self.assertEqual(mapping.formatter('bb'), 'bb')
@ -59,40 +57,4 @@ class AbstractConnector(TestCase):
self.assertEqual(mapping.local_field, 'isbn') self.assertEqual(mapping.local_field, 'isbn')
self.assertEqual(mapping.remote_field, 'isbn') self.assertEqual(mapping.remote_field, 'isbn')
self.assertEqual(mapping.formatter, formatter) self.assertEqual(mapping.formatter, formatter)
self.assertEqual(mapping.model, None)
self.assertEqual(mapping.formatter('bb'), 'aabb') 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 ''' ''' testing book data connectors '''
from dateutil import parser
from django.test import TestCase
import json import json
import pathlib import pathlib
from dateutil import parser
from django.test import TestCase
import pytz import pytz
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors.openlibrary import Connector from bookwyrm.connectors.openlibrary import Connector
from bookwyrm.connectors.openlibrary import get_languages, get_description from bookwyrm.connectors.openlibrary import get_languages, get_description
from bookwyrm.connectors.openlibrary import pick_default_edition, get_openlibrary_key from bookwyrm.connectors.openlibrary import pick_default_edition, \
from bookwyrm.connectors.abstract_connector import SearchResult, get_date get_openlibrary_key
from bookwyrm.connectors.abstract_connector import SearchResult
class Openlibrary(TestCase): class Openlibrary(TestCase):
@ -67,12 +68,6 @@ class Openlibrary(TestCase):
self.assertEqual(description, expected) 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): def test_get_languages(self):
languages = get_languages(self.edition_data['languages']) languages = get_languages(self.edition_data['languages'])
self.assertEqual(languages, ['English']) self.assertEqual(languages, ['English'])
@ -81,4 +76,3 @@ class Openlibrary(TestCase):
def test_get_ol_key(self): def test_get_ol_key(self):
key = get_openlibrary_key('/books/OL27320736M') key = get_openlibrary_key('/books/OL27320736M')
self.assertEqual(key, 'OL27320736M') self.assertEqual(key, 'OL27320736M')

View file

@ -1,42 +1,275 @@
''' testing models ''' ''' 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.test import TestCase
from django.utils import timezone
from bookwyrm import models, settings from bookwyrm import models, settings
class Status(TestCase): class Status(TestCase):
''' lotta types of statuses '''
def setUp(self): 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) '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') image_file = pathlib.Path(__file__).parent.joinpath(
models.Comment.objects.create(user=user, content='content', book=book) '../../static/images/default_avi.jpg')
models.Quotation.objects.create( image = Image.open(image_file)
user=user, content='content', book=book, quote='blah') output = BytesIO()
models.Review.objects.create( image.save(output, format=image.format)
user=user, content='content', book=book, rating=3) self.book.cover.save(
'test.jpg',
ContentFile(output.getvalue())
)
def test_status(self): def test_status_generated_fields(self):
status = models.Status.objects.first() ''' setting remote id '''
status = models.Status.objects.create(content='bleh', user=self.user)
expected_id = 'https://%s/user/mouse/status/%d' % \ expected_id = 'https://%s/user/mouse/status/%d' % \
(settings.DOMAIN, status.id) (settings.DOMAIN, status.id)
self.assertEqual(status.remote_id, expected_id) self.assertEqual(status.remote_id, expected_id)
self.assertEqual(status.privacy, 'public')
def test_comment(self): def test_replies(self):
comment = models.Comment.objects.first() ''' get a list of replies '''
expected_id = 'https://%s/user/mouse/comment/%d' % \ parent = models.Status.objects.create(content='hi', user=self.user)
(settings.DOMAIN, comment.id) child = models.Status.objects.create(
self.assertEqual(comment.remote_id, expected_id) 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): replies = models.Status.replies(parent)
quotation = models.Quotation.objects.first() self.assertEqual(replies.count(), 2)
expected_id = 'https://%s/user/mouse/quotation/%d' % \ self.assertEqual(replies.first(), child)
(settings.DOMAIN, quotation.id) # should select subclasses
self.assertEqual(quotation.remote_id, expected_id) self.assertIsInstance(replies.last(), models.Review)
def test_review(self): def test_status_type(self):
review = models.Review.objects.first() ''' class name '''
expected_id = 'https://%s/user/mouse/review/%d' % \ self.assertEqual(models.Status().status_type, 'Note')
(settings.DOMAIN, review.id) self.assertEqual(models.Review().status_type, 'Review')
self.assertEqual(review.remote_id, expected_id) 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() status = models.Quotation.objects.get()
self.assertEqual( self.assertEqual(
status.remote_id, 'https://example.com/user/mouse/quotation/13') status.remote_id, 'https://example.com/user/mouse/quotation/13')
self.assertEqual(status.quote, 'quote body') self.assertEqual(status.quote, '<p>quote body</p>')
self.assertEqual(status.content, 'commentary') self.assertEqual(status.content, '<p>commentary</p>')
self.assertEqual(status.user, self.local_user) self.assertEqual(status.user, self.local_user)
self.assertEqual(models.Status.objects.count(), 2) self.assertEqual(models.Status.objects.count(), 2)
@ -284,7 +284,7 @@ class Incoming(TestCase):
incoming.handle_create(activity) incoming.handle_create(activity)
status = models.Status.objects.last() 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.assertEqual(status.mention_users.first(), self.local_user)
self.assertTrue( self.assertTrue(
models.Notification.objects.filter(user=self.local_user).exists()) models.Notification.objects.filter(user=self.local_user).exists())
@ -306,7 +306,7 @@ class Incoming(TestCase):
incoming.handle_create(activity) incoming.handle_create(activity)
status = models.Status.objects.last() 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.assertEqual(status.reply_parent, self.status)
self.assertTrue( self.assertTrue(
models.Notification.objects.filter(user=self.local_user)) models.Notification.objects.filter(user=self.local_user))
@ -410,7 +410,10 @@ class Incoming(TestCase):
'actor': self.remote_user.remote_id, 'actor': self.remote_user.remote_id,
'object': self.status.to_activity(), '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() boost = models.Boost.objects.get()
self.assertEqual(boost.boosted_status, self.status) self.assertEqual(boost.boosted_status, self.status)
notification = models.Notification.objects.get() notification = models.Notification.objects.get()

View file

@ -223,6 +223,8 @@ def resolve_book(request):
remote_id = request.POST.get('remote_id') remote_id = request.POST.get('remote_id')
connector = books_manager.get_or_create_connector(remote_id) connector = books_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(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) return redirect('/book/%d' % book.id)

View file

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

View file

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