Merge branch 'main' into outgoing-tests

This commit is contained in:
Mouse Reeve 2020-12-21 14:39:39 -08:00
commit 98aa946519
63 changed files with 1482 additions and 700 deletions

View file

@ -63,7 +63,6 @@ class ActivityObject:
setattr(self, field.name, value) setattr(self, field.name, value)
@transaction.atomic
def to_model(self, model, instance=None, save=True): def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance ''' ''' convert from an activity to a model instance '''
if not isinstance(self, model.activity_serializer): if not isinstance(self, model.activity_serializer):
@ -74,6 +73,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()
@ -88,13 +90,14 @@ class ActivityObject:
if not save: if not save:
return instance return instance
# we can't set many to many and reverse fields on an unsaved object with transaction.atomic():
instance.save() # we can't set many to many and reverse fields on an unsaved object
instance.save()
# add many to many fields, which have to be set post-save # add many to many fields, which have to be set post-save
for field in instance.many_to_many_fields: for field in instance.many_to_many_fields:
# mention books/users, for example # mention books/users, for example
field.set_field_from_activity(instance, self) field.set_field_from_activity(instance, self)
# reversed relationships in the models # reversed relationships in the models
for (model_field_name, activity_field_name) in \ for (model_field_name, activity_field_name) in \
@ -103,20 +106,23 @@ class ActivityObject:
values = getattr(self, activity_field_name) values = getattr(self, activity_field_name)
if values is None or values is MISSING: if values is None or values is MISSING:
continue continue
model_field = getattr(model, model_field_name)
try: try:
# this is for one to many # this is for one to many
related_model = getattr(model, model_field_name).field.model related_model = model_field.field.model
related_field_name = model_field.field.name
except AttributeError: except AttributeError:
# it's a one to one or foreign key # it's a one to one or foreign key
related_model = getattr(model, model_field_name)\ related_model = model_field.related.related_model
.related.related_model related_field_name = model_field.related.related_name
values = [values] values = [values]
for item in values: for item in values:
set_related_field.delay( set_related_field.delay(
related_model.__name__, related_model.__name__,
instance.__class__.__name__, instance.__class__.__name__,
instance.__class__.__name__.lower(), related_field_name,
instance.remote_id, instance.remote_id,
item item
) )
@ -142,23 +148,25 @@ def set_related_field(
require_ready=True require_ready=True
) )
if isinstance(data, str): with transaction.atomic():
item = resolve_remote_id(model, data, save=False) if isinstance(data, str):
else: item = resolve_remote_id(model, data, save=False)
# look for a match based on all the available data else:
item = model.find_existing(data) # look for a match based on all the available data
if not item: item = model.find_existing(data)
# create a new model instance if not item:
item = model.activity_serializer(**data) # create a new model instance
item = item.to_model(model, save=False) item = model.activity_serializer(**data)
# this must exist because it's the object that triggered this function item = item.to_model(model, save=False)
instance = origin_model.find_existing_by_remote_id(related_remote_id) # this must exist because it's the object that triggered this function
if not instance: instance = origin_model.find_existing_by_remote_id(related_remote_id)
raise ValueError('Invalid related remote id: %s' % related_remote_id) if not instance:
raise ValueError(
'Invalid related remote id: %s' % related_remote_id)
# edition.parent_work = instance, for example # edition.parent_work = instance, for example
setattr(item, related_field_name, instance) setattr(item, related_field_name, instance)
item.save() item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True): def resolve_remote_id(model, remote_id, refresh=False, save=True):

View file

@ -38,7 +38,7 @@ class Edition(Book):
isbn13: str = '' isbn13: str = ''
oclcNumber: str = '' oclcNumber: str = ''
asin: str = '' asin: str = ''
pages: str = '' pages: int = None
physicalFormat: str = '' physicalFormat: str = ''
publishers: List[str] = field(default_factory=lambda: []) publishers: List[str] = field(default_factory=lambda: [])
@ -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

@ -23,6 +23,7 @@ class Note(ActivityObject):
cc: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {}) replies: Dict = field(default_factory=lambda: {})
inReplyTo: str = '' inReplyTo: str = ''
summary: str = ''
tag: List[Link] = field(default_factory=lambda: []) tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: []) attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False sensitive: bool = False
@ -52,8 +53,8 @@ class Comment(Note):
@dataclass(init=False) @dataclass(init=False)
class Review(Comment): class Review(Comment):
''' a full book review ''' ''' a full book review '''
name: str name: str = None
rating: int rating: int = None
type: str = 'Review' type: str = 'Review'

View file

@ -18,13 +18,13 @@ class PublicKey(ActivityObject):
class Person(ActivityObject): class Person(ActivityObject):
''' actor activitypub json ''' ''' actor activitypub json '''
preferredUsername: str preferredUsername: str
name: str
inbox: str inbox: str
outbox: str outbox: str
followers: str followers: str
summary: str
publicKey: PublicKey publicKey: PublicKey
endpoints: Dict endpoints: Dict
name: str = None
summary: str = None
icon: Image = field(default_factory=lambda: {}) icon: Image = field(default_factory=lambda: {})
bookwyrmUser: bool = False bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False manuallyApprovesFollowers: str = False

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass
from typing import List from typing import List
from .base_activity import ActivityObject, Signature from .base_activity import ActivityObject, Signature
from .book import Book from .book import Edition
@dataclass(init=False) @dataclass(init=False)
class Verb(ActivityObject): class Verb(ActivityObject):
@ -73,7 +73,7 @@ class Add(Verb):
@dataclass(init=False) @dataclass(init=False)
class AddBook(Verb): class AddBook(Verb):
'''Add activity that's aware of the book obj ''' '''Add activity that's aware of the book obj '''
target: Book target: Edition
type: str = 'Add' type: str = 'Add'

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.set(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):
@ -220,7 +174,7 @@ def pick_default_edition(options):
if len(options) == 1: if len(options) == 1:
return options[0] return options[0]
options = [e for e in options if e.get('cover')] or options options = [e for e in options if e.get('covers')] or options
options = [e for e in options if \ options = [e for e in options if \
'/languages/eng' in str(e.get('languages'))] or options '/languages/eng' in str(e.get('languages'))] or options
formats = ['paperback', 'hardcover', 'mass market paperback'] formats = ['paperback', 'hardcover', 'mass market paperback']

View file

@ -13,7 +13,7 @@ class Connector(AbstractConnector):
that gets implemented it will totally rule ''' that gets implemented it will totally rule '''
vector = SearchVector('title', weight='A') +\ vector = SearchVector('title', weight='A') +\
SearchVector('subtitle', weight='B') +\ SearchVector('subtitle', weight='B') +\
SearchVector('author_text', weight='C') +\ SearchVector('authors__name', weight='C') +\
SearchVector('isbn_13', weight='A') +\ SearchVector('isbn_13', weight='A') +\
SearchVector('isbn_10', weight='A') +\ SearchVector('isbn_10', weight='A') +\
SearchVector('openlibrary_key', weight='C') +\ SearchVector('openlibrary_key', weight='C') +\

View file

@ -60,25 +60,36 @@ class RatingForm(CustomForm):
class ReviewForm(CustomForm): class ReviewForm(CustomForm):
class Meta: class Meta:
model = models.Review model = models.Review
fields = ['user', 'book', 'name', 'content', 'rating', 'privacy'] fields = [
'user', 'book',
'name', 'content', 'rating',
'content_warning', 'sensitive',
'privacy']
class CommentForm(CustomForm): class CommentForm(CustomForm):
class Meta: class Meta:
model = models.Comment model = models.Comment
fields = ['user', 'book', 'content', 'privacy'] fields = [
'user', 'book', 'content',
'content_warning', 'sensitive',
'privacy']
class QuotationForm(CustomForm): class QuotationForm(CustomForm):
class Meta: class Meta:
model = models.Quotation model = models.Quotation
fields = ['user', 'book', 'quote', 'content', 'privacy'] fields = [
'user', 'book', 'quote', 'content',
'content_warning', 'sensitive', 'privacy']
class ReplyForm(CustomForm): class ReplyForm(CustomForm):
class Meta: class Meta:
model = models.Status model = models.Status
fields = ['user', 'content', 'reply_parent', 'privacy'] fields = [
'user', 'content', 'content_warning', 'sensitive',
'reply_parent', 'privacy']
class EditUserForm(CustomForm): class EditUserForm(CustomForm):

View file

@ -57,7 +57,6 @@ def shared_inbox(request):
'Announce': handle_boost, 'Announce': handle_boost,
'Add': { 'Add': {
'Edition': handle_add, 'Edition': handle_add,
'Work': handle_add,
}, },
'Undo': { 'Undo': {
'Follow': handle_unfollow, 'Follow': handle_unfollow,
@ -198,28 +197,15 @@ 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 = []
if status.reply_parent and status.reply_parent.user.local: if status.reply_parent and status.reply_parent.user.local:
notified.append(status.reply_parent.user)
status_builder.create_notification( status_builder.create_notification(
status.reply_parent.user, status.reply_parent.user,
'REPLY', 'REPLY',
@ -228,7 +214,7 @@ def handle_create(activity):
) )
if status.mention_users.exists(): if status.mention_users.exists():
for mentioned_user in status.mention_users.all(): for mentioned_user in status.mention_users.all():
if not mentioned_user.local: if not mentioned_user.local or mentioned_user in notified:
continue continue
status_builder.create_notification( status_builder.create_notification(
mentioned_user, mentioned_user,

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-12-12 00:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0016_auto_20201211_2026'),
]
operations = [
migrations.AlterField(
model_name='readthrough',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-12-16 01:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0017_auto_20201212_0059'),
('bookwyrm', '0022_auto_20201212_1744'),
]
operations = [
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-12-16 17:21
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0023_auto_20201214_0511'),
('bookwyrm', '0023_merge_20201216_0112'),
]
operations = [
]

View file

@ -0,0 +1,39 @@
# Generated by Django 3.0.7 on 2020-12-17 00:46
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0024_merge_20201216_1721'),
]
operations = [
migrations.AlterField(
model_name='author',
name='bio',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='description',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='quotation',
name='quote',
field=bookwyrm.models.fields.HtmlField(),
),
migrations.AlterField(
model_name='status',
name='content',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.HtmlField(default=''),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-12-17 03:17
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0025_auto_20201217_0046'),
]
operations = [
migrations.AddField(
model_name='status',
name='content_warning',
field=bookwyrm.models.fields.CharField(blank=True, max_length=500, null=True),
),
]

View file

@ -0,0 +1,24 @@
# Generated by Django 3.0.7 on 2020-12-20 20:07
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0026_status_content_warning'),
]
operations = [
migrations.AlterField(
model_name='user',
name='name',
field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-12-21 19:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0027_auto_20201220_2007'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='author_text',
),
]

View file

@ -21,18 +21,17 @@ class Author(ActivitypubMixin, BookWyrmModel):
# idk probably other keys would be useful here? # idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True) born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True) died = fields.DateTimeField(blank=True, null=True)
name = fields.CharField(max_length=255) name = fields.CharField(max_length=255, deduplication_field=True)
aliases = fields.ArrayField( aliases = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
bio = fields.TextField(null=True, blank=True) bio = fields.HtmlField(null=True, blank=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it ''' ''' handle remote vs origin ids '''
if self.id and not self.remote_id: if self.id:
self.remote_id = self.get_remote_id() self.remote_id = self.get_remote_id()
else:
if not self.id:
self.origin_id = self.remote_id self.origin_id = self.remote_id
self.remote_id = None self.remote_id = None
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View file

@ -151,9 +151,9 @@ class ActivitypubMixin:
return self.activity_serializer(**activity).serialize() return self.activity_serializer(**activity).serialize()
def to_create_activity(self, user): def to_create_activity(self, user, **kwargs):
''' returns the object wrapped in a Create activity ''' ''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity() activity_object = self.to_activity(**kwargs)
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content'] content = activity_object['content']

View file

@ -36,7 +36,7 @@ class Book(ActivitypubMixin, BookWyrmModel):
title = fields.CharField(max_length=255) title = fields.CharField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True) sort_title = fields.CharField(max_length=255, blank=True, null=True)
subtitle = fields.CharField(max_length=255, blank=True, null=True) subtitle = fields.CharField(max_length=255, blank=True, null=True)
description = fields.TextField(blank=True, null=True) description = fields.HtmlField(blank=True, null=True)
languages = fields.ArrayField( languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
@ -51,22 +51,45 @@ class Book(ActivitypubMixin, BookWyrmModel):
# TODO: include an annotation about the type of authorship (ie, translator) # TODO: include an annotation about the type of authorship (ie, translator)
authors = fields.ManyToManyField('Author') authors = fields.ManyToManyField('Author')
# preformatted authorship string for search and easier display # preformatted authorship string for search and easier display
author_text = models.CharField(max_length=255, blank=True, null=True) cover = fields.ImageField(
cover = fields.ImageField(upload_to='covers/', blank=True, null=True) upload_to='covers/', blank=True, null=True, alt_field='alt_text')
first_published_date = fields.DateTimeField(blank=True, null=True) first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True)
objects = InheritanceManager() objects = InheritanceManager()
@property
def author_text(self):
''' format a list of authors '''
return ', '.join(a.name for a in self.authors.all())
@property
def edition_info(self):
''' properties of this edition, as a string '''
items = [
self.physical_format if hasattr(self, 'physical_format') else None,
self.languages[0] + ' language' if self.languages and \
self.languages[0] != 'English' else None,
str(self.published_date.year) if self.published_date else None,
]
return ', '.join(i for i in items if i)
@property
def alt_text(self):
''' image alt test '''
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 '''
if not isinstance(self, Edition) and not isinstance(self, Work): if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works') raise ValueError('Books should be added as Editions or Works')
if self.id and not self.remote_id: if self.id:
self.remote_id = self.get_remote_id() self.remote_id = self.get_remote_id()
else:
if not self.id:
self.origin_id = self.remote_id self.origin_id = self.remote_id
self.remote_id = None self.remote_id = None
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@ -92,7 +115,8 @@ class Work(OrderedCollectionPageMixin, Book):
default_edition = fields.ForeignKey( default_edition = fields.ForeignKey(
'Edition', 'Edition',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True null=True,
load_remote=False
) )
def get_default_edition(self): def get_default_edition(self):

View file

@ -5,7 +5,6 @@ from uuid import uuid4
import dateutil.parser import dateutil.parser
from dateutil.parser import ParserError from dateutil.parser import ParserError
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField as DjangoArrayField from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@ -13,8 +12,9 @@ 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 bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.connectors import get_image from bookwyrm.connectors import get_image
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.settings import DOMAIN
def validate_remote_id(value): def validate_remote_id(value):
@ -26,6 +26,15 @@ def validate_remote_id(value):
) )
def validate_username(value):
''' make sure usernames look okay '''
if not re.match(r'^[A-Za-z\-_\.]+$', value):
raise ValidationError(
_('%(value)s is not a valid remote_id'),
params={'value': value},
)
class ActivitypubFieldMixin: class ActivitypubFieldMixin:
''' make a database field serializable ''' ''' make a database field serializable '''
def __init__(self, *args, \ def __init__(self, *args, \
@ -63,6 +72,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:
@ -92,12 +104,19 @@ class ActivitypubFieldMixin:
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
''' default (de)serialization for foreign key and one to one ''' ''' default (de)serialization for foreign key and one to one '''
def __init__(self, *args, load_remote=True, **kwargs):
self.load_remote = load_remote
super().__init__(*args, **kwargs)
def field_from_activity(self, value): def field_from_activity(self, value):
if not value: if not value:
return None return None
related_model = self.related_model related_model = self.related_model
if isinstance(value, dict) and value.get('id'): if isinstance(value, dict) and value.get('id'):
if not self.load_remote:
# only look in the local database
return related_model.find_existing(value)
# this is an activitypub object, which we can deserialize # this is an activitypub object, which we can deserialize
activity_serializer = related_model.activity_serializer activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model) return activity_serializer(**value).to_model(related_model)
@ -108,6 +127,9 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
# we don't know what this is, ignore it # we don't know what this is, ignore it
return None return None
# gets or creates the model field from the remote id # gets or creates the model field from the remote id
if not self.load_remote:
# only look in the local database
return related_model.find_existing_by_remote_id(value)
return activitypub.resolve_remote_id(related_model, value) return activitypub.resolve_remote_id(related_model, value)
@ -134,7 +156,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
_('username'), _('username'),
max_length=150, max_length=150,
unique=True, unique=True,
validators=[AbstractUser.username_validator], validators=[validate_username],
error_messages={ error_messages={
'unique': _('A user with that username already exists.'), 'unique': _('A user with that username already exists.'),
}, },
@ -276,6 +298,8 @@ class TagField(ManyToManyField):
for link_json in value: for link_json in value:
link = activitypub.Link(**link_json) link = activitypub.Link(**link_json)
tag_type = link.type if link.type != 'Mention' else 'Person' tag_type = link.type if link.type != 'Mention' else 'Person'
if tag_type == 'Book':
tag_type = 'Edition'
if tag_type != self.related_model.activity_serializer.type: if tag_type != self.related_model.activity_serializer.type:
# tags can contain multiple types # tags can contain multiple types
continue continue
@ -285,18 +309,22 @@ class TagField(ManyToManyField):
return items return items
def image_serializer(value): def image_serializer(value, alt):
''' helper for serializing images ''' ''' helper for serializing images '''
if value and hasattr(value, 'url'): if value and hasattr(value, 'url'):
url = value.url url = value.url
else: else:
return None return None
url = 'https://%s%s' % (DOMAIN, url) url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url) return activitypub.Image(url=url, name=alt)
class ImageField(ActivitypubFieldMixin, models.ImageField): class ImageField(ActivitypubFieldMixin, models.ImageField):
''' activitypub-aware image field ''' ''' activitypub-aware image field '''
def __init__(self, *args, alt_field=None, **kwargs):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ # pylint: disable=arguments-differ
def set_field_from_activity(self, instance, data, save=True): def set_field_from_activity(self, instance, data, save=True):
''' helper function for assinging a value to the field ''' ''' helper function for assinging a value to the field '''
@ -306,9 +334,19 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
return return
getattr(instance, self.name).save(*formatted, save=save) getattr(instance, self.name).save(*formatted, save=save)
def set_activity_from_field(self, activity, instance):
value = getattr(instance, self.name)
if value is None:
return
alt_text = getattr(instance, self.alt_field)
formatted = self.field_to_activity(value, alt_text)
def field_to_activity(self, value): key = self.get_activitypub_field()
return image_serializer(value) activity[key] = formatted
def field_to_activity(self, value, alt=None):
return image_serializer(value, alt)
def field_from_activity(self, value): def field_from_activity(self, value):
@ -353,6 +391,15 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
except (ParserError, TypeError): except (ParserError, TypeError):
return None return None
class HtmlField(ActivitypubFieldMixin, models.TextField):
''' a text field for storing html '''
def field_from_activity(self, value):
if not value or value == MISSING:
return None
sanitizer = InputHtmlParser()
sanitizer.feed(value)
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

@ -3,7 +3,7 @@ import re
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import BookWyrmModel from .base_model import ActivitypubMixin, BookWyrmModel
from .base_model import OrderedCollectionMixin from .base_model import OrderedCollectionMixin
from . import fields from . import fields
@ -51,7 +51,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
unique_together = ('user', 'identifier') unique_together = ('user', 'identifier')
class ShelfBook(BookWyrmModel): class ShelfBook(ActivitypubMixin, BookWyrmModel):
''' many to many join table for books and shelves ''' ''' many to many join table for books and shelves '''
book = fields.ForeignKey( book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object') 'Edition', on_delete=models.PROTECT, activitypub_field='object')

View file

@ -1,7 +1,11 @@
''' models for storing different kinds of Activities ''' ''' models for storing different kinds of Activities '''
from django.utils import timezone from dataclasses import MISSING
import re
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
@ -14,10 +18,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc ''' ''' any post, like a reply to a review, etc '''
user = fields.ForeignKey( user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='attributedTo') 'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
content = fields.TextField(blank=True, null=True) content = fields.HtmlField(blank=True, null=True)
mention_users = fields.TagField('User', related_name='mention_user') mention_users = fields.TagField('User', related_name='mention_user')
mention_books = fields.TagField('Edition', related_name='mention_book') mention_books = fields.TagField('Edition', related_name='mention_book')
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
content_warning = fields.CharField(
max_length=500, blank=True, null=True, activitypub_field='summary')
privacy = fields.PrivacyField(max_length=255) privacy = fields.PrivacyField(max_length=255)
sensitive = fields.BooleanField(default=False) sensitive = fields.BooleanField(default=False)
# created date is different than publish date because of federated posts # created date is different than publish date because of federated posts
@ -44,6 +50,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
@ -57,6 +84,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(
@ -78,17 +110,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
activity['replies'] = self.to_replies() activity['replies'] = self.to_replies()
# "pure" serialization for non-bookwyrm instances # "pure" serialization for non-bookwyrm instances
if pure: if pure and hasattr(self, 'pure_content'):
activity['content'] = self.pure_content activity['content'] = self.pure_content
if 'name' in activity: if 'name' in activity:
activity['name'] = self.pure_name activity['name'] = self.pure_name
activity['type'] = self.pure_type activity['type'] = self.pure_type
activity['attachment'] = [ activity['attachment'] = [
image_serializer(b.cover) for b in self.mention_books.all() \ image_serializer(b.cover, b.alt_text) \
if b.cover] for b in self.mention_books.all()[:4] if b.cover]
if hasattr(self, 'book'): if hasattr(self, 'book'):
activity['attachment'].append( activity['attachment'].append(
image_serializer(self.book.cover) image_serializer(self.book.cover, self.book.alt_text)
) )
return activity return activity
@ -125,8 +157,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 '%s<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'
@ -134,15 +166,17 @@ class Comment(Status):
class Quotation(Status): class Quotation(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
quote = fields.TextField() quote = fields.HtmlField()
book = fields.ForeignKey( book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook') 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@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' % ( quote = re.sub(r'^<p>', '<p>"', self.quote)
self.quote, quote = re.sub(r'</p>$', '"</p>', quote)
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
quote,
self.book.remote_id, self.book.remote_id,
self.book.title, self.book.title,
self.content, self.content,
@ -182,8 +216,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'
@ -240,7 +273,7 @@ class Boost(Status):
class ReadThrough(BookWyrmModel): class ReadThrough(BookWyrmModel):
''' Store progress through a book in the database. ''' ''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Book', on_delete=models.PROTECT) book = models.ForeignKey('Edition', on_delete=models.PROTECT)
pages_read = models.IntegerField( pages_read = models.IntegerField(
null=True, null=True,
blank=True) blank=True)

View file

@ -42,7 +42,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
blank=True, blank=True,
) )
outbox = fields.RemoteIdField(unique=True) outbox = fields.RemoteIdField(unique=True)
summary = fields.TextField(default='') summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False) local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True) bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField( localname = models.CharField(
@ -51,9 +51,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
unique=True unique=True
) )
# name is your display name, which you can change at will # name is your display name, which you can change at will
name = fields.CharField(max_length=100, default='') name = fields.CharField(max_length=100, null=True, blank=True)
avatar = fields.ImageField( avatar = fields.ImageField(
upload_to='avatars/', blank=True, null=True, activitypub_field='icon') upload_to='avatars/', blank=True, null=True,
activitypub_field='icon', alt_field='alt_text')
followers = fields.ManyToManyField( followers = fields.ManyToManyField(
'self', 'self',
link_only=True, link_only=True,
@ -90,10 +91,16 @@ 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
def alt_text(self):
''' alt text with username '''
return 'avatar for %s' % (self.localname or self.username)
@property @property
def display_name(self): def display_name(self):
''' show the cleanest version of the user's name possible ''' ''' show the cleanest version of the user's name possible '''
if self.name != '': if self.name and self.name != '':
return self.name return self.name
return self.localname or self.username return self.localname or self.username

View file

@ -4,12 +4,14 @@ import re
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse from django.http import HttpResponseNotFound, JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from markdown import markdown
from requests import HTTPError from requests import HTTPError
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.broadcast import broadcast from bookwyrm.broadcast import broadcast
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.status import create_notification from bookwyrm.status import create_notification
from bookwyrm.status import create_generated_note from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status from bookwyrm.status import delete_status
@ -209,15 +211,15 @@ def handle_delete_status(user, status):
def handle_status(user, form): def handle_status(user, form):
''' generic handler for statuses ''' ''' generic handler for statuses '''
status = form.save() status = form.save(commit=False)
if not status.sensitive and status.content_warning:
# the cw text field remains populated when you click "remove"
status.content_warning = None
status.save()
# inspect the text for user tags # 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:]
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
@ -228,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
@ -238,6 +241,17 @@ def handle_status(user, form):
related_user=user, related_user=user,
related_status=status related_status=status
) )
# add mentions
content = status.content
for (username, url) in matches:
content = re.sub(
r'%s([^@])' % username,
r'<a href="%s">%s</a>\g<1>' % (url, username),
content)
if not isinstance(status, models.GeneratedNote):
status.content = to_markdown(content)
if hasattr(status, 'quote'):
status.quote = to_markdown(status.quote)
status.save() status.save()
# notify reply parent or tagged users # notify reply parent or tagged users
@ -252,9 +266,22 @@ def handle_status(user, form):
broadcast(user, status.to_create_activity(user), software='bookwyrm') broadcast(user, status.to_create_activity(user), software='bookwyrm')
# re-format the activity for non-bookwyrm servers # re-format the activity for non-bookwyrm servers
if hasattr(status, 'pure_activity_serializer'): remote_activity = status.to_create_activity(user, pure=True)
remote_activity = status.to_create_activity(user, pure=True) broadcast(user, remote_activity, software='other')
broadcast(user, remote_activity, software='other')
def to_markdown(content):
''' catch links and convert to markdown '''
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)
content = markdown(content)
# sanitize resulting html
sanitizer = InputHtmlParser()
sanitizer.feed(content)
return sanitizer.get_output()
def handle_tag(user, tag): def handle_tag(user, tag):
@ -312,15 +339,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

@ -1,12 +1,16 @@
''' html parser to clean up incoming text from unknown sources ''' ''' html parser to clean up incoming text from unknown sources '''
from html.parser import HTMLParser from html.parser import HTMLParser
class InputHtmlParser(HTMLParser): class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method
''' Removes any html that isn't allowed_tagsed from a block ''' ''' Removes any html that isn't allowed_tagsed from a block '''
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

@ -65,6 +65,7 @@ input.toggle-control:checked ~ .modal.toggle-content {
.cover-container { .cover-container {
height: 250px; height: 250px;
width: max-content; width: max-content;
max-width: 250px;
} }
.cover-container.is-medium { .cover-container.is-medium {
height: 150px; height: 150px;
@ -136,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

@ -4,13 +4,21 @@
{% block content %} {% block content %}
<div class="block"> <div class="block">
<div class="level"> <div class="columns">
<h1 class="title level-left"> <div class="column">
<span>{% include 'snippets/book_titleby.html' with book=book %}</span> <h1 class="title">
</h1> {{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>{% endif %}
</h1>
{% if book.authors %}
<h2 class="subtitle">
by {% include 'snippets/authors.html' with book=book %}
</h2>
{% endif %}
</div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="level-right"> <div class="column is-narrow">
<a href="{{ book.id }}/edit"> <a href="{{ book.id }}/edit">
<span class="icon icon-pencil"> <span class="icon icon-pencil">
<span class="is-sr-only">Edit Book</span> <span class="is-sr-only">Edit Book</span>
@ -91,87 +99,26 @@
{% endif %} {% endif %}
</div> </div>
{% for readthrough in readthroughs %} {# user's relationship to the book #}
<div class="content block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="show-readthrough-{{ readthrough.id }}" checked>
<div class="toggle-content hidden">
<dl>
{% if readthrough.start_date %}
<dt>Started reading:</dt>
<dd>{{ readthrough.start_date | naturalday }}</dd>
{% endif %}
{% if readthrough.finish_date %}
<dt>Finished reading:</dt>
<dd>{{ readthrough.finish_date | naturalday }}</dd>
{% endif %}
</dl>
<div class="field is-grouped">
<label class="button is-small" for="edit-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit read-through dates</span>
</span>
</label>
<label class="button is-small" for="delete-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-x">
<span class="is-sr-only">Delete this read-through</span>
</span>
</label>
</div>
</div>
</div>
<div class="block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="edit-readthrough-{{ readthrough.id }}">
<div class="toggle-content hidden">
<div class="box">
<form name="edit-readthrough" action="/edit-readthrough" method="post">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<div class="field">
<label class="label">
Started reading
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field">
<label class="label">
Finished reading
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="show-readthrough-{{ readthrough.id }}" role="button" tabindex="0">Cancel</label>
</div>
</form>
</div>
</div>
</div>
<div> <div>
<input class="toggle-control" type="checkbox" name="delete-readthrough-{{ readthrough.id }}" id="delete-readthrough-{{ readthrough.id }}"> {% for shelf in user_shelves %}
<div class="modal toggle-content hidden"> <p>
<div class="modal-background"></div> This edition is on your <a href="/user/{{ user.localname }}/shelf/{{ shelf.shelf.identifier }}">{{ shelf.shelf.name }}</a> shelf.
<div class="modal-card"> {% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
<header class="modal-card-head"> </p>
<p class="modal-card-title">Delete this read-though?</p> {% endfor %}
<label class="delete" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</header> {% for shelf in other_edition_shelves %}
<footer class="modal-card-foot"> <p>
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST"> 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.
{% csrf_token %} {% include 'snippets/switch_edition_button.html' with edition=book %}
<input type="hidden" name="id" value="{{ readthrough.id }}"> </p>
<button class="button is-danger is-light" type="submit"> {% endfor %}
Delete
</button> {% for readthrough in readthroughs %}
<label for="delete-readthrough-{{ readthrough.id }}" class="button" role="button" tabindex="0">Cancel</button> {% include 'snippets/readthrough.html' with readthrough=readthrough %}
</form> {% endfor %}
</footer>
</div>
<label class="modal-close is-large" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</div>
</div> </div>
{% endfor %}
{% if request.user.is_authenticated %} {% if request.user.is_authenticated %}
<div class="box"> <div class="box">

View file

@ -44,11 +44,13 @@
<div> <div>
<input class="toggle-control" type="radio" name="recent-books" id="book-{{ book.id }}" {% if shelf_counter == 1 and forloop.first %}checked{% endif %}> <input class="toggle-control" type="radio" name="recent-books" id="book-{{ book.id }}" {% if shelf_counter == 1 and forloop.first %}checked{% endif %}>
<div class="toggle-content hidden" role="tabpanel" id="book-{{ book.id }}-panel"> <div class="toggle-content hidden" role="tabpanel" id="book-{{ book.id }}-panel">
<div class="block"> <div class="box">
{% include 'snippets/book_titleby.html' with book=book %} <div class="block">
{% include 'snippets/shelve_button.html' with book=book %} {% include 'snippets/book_titleby.html' with book=book %}
{% include 'snippets/shelve_button.html' with book=book %}
</div>
{% include 'snippets/create_status.html' with book=book %}
</div> </div>
{% include 'snippets/create_status.html' with book=book %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}

View file

@ -54,7 +54,7 @@
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}"> <div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
<div class="columns"> <div class="columns">
<div class="column"> <div class="column">
<a href="{{ notification.related_status.remote_id }}">{{ notification.related_status.content | truncatewords_html:10 }}</a> <a href="{{ notification.related_status.remote_id }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
</div> </div>
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}"> <div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
{{ notification.related_status.published_date | post_date }} {{ notification.related_status.published_date | post_date }}

View file

@ -1 +1 @@
<a href="/author/{{ book.authors.first.id }}" class="author">{{ book.authors.first.name }}</a> {% for author in book.authors.all %}<a href="/author/{{ author.id }}" class="author">{{ author.name }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}

View file

@ -1,3 +1,3 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
<img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" alt="avatar for {{ user|username }}"> <img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" alt="{{ user.alt_text }}">

View file

@ -1,13 +1,13 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
<div class="cover-container is-{{ size }}"> <div class="cover-container is-{{ size }}">
{% if book.cover %} {% if book.cover %}
<img class="book-cover" src="/images/{{ book.cover }}" alt="{% include 'snippets/cover_alt.html' with book=book %}"> <img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}">
{% else %} {% else %}
<div class="no-cover book-cover"> <div class="no-cover book-cover">
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover"> <img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
<div> <div>
<p>{{ book.title }}</p> <p>{{ book.title }}</p>
<p>({{ book|edition_info }})</p> <p>({{ book.edition_info }})</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View file

@ -1,16 +1,11 @@
<div class="columns"> <div class="columns is-multiline">
{% for book in books %} {% for book in books %}
{% if forloop.counter0|divisibleby:"4" %}
</div>
<div class="columns">
{% endif %}
<div class="column is-narrow"> <div class="column is-narrow">
<div class="box"> <div class="box">
<a href="/book/{{ book.id }}"> <a href="/book/{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book %} {% include 'snippets/book_cover.html' with book=book %}
</a> </a>
{% include 'snippets/rate_action.html' with user=request.user book=book %} {% include 'snippets/shelve_button.html' with book=book switch_mode=True %}
{% include 'snippets/shelve_button.html' with book=book %}
</div> </div>
</div> </div>
{% 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

@ -0,0 +1,20 @@
{% load bookwyrm_tags %}
{% with 0|uuid as uuid %}
<div class="control">
<div>
<input type="radio" class="toggle-control" name="sensitive" value="false" id="hide-spoilers-{{ uuid }}" {% if not parent_status.content_warning %}checked{% endif %}>
<div class="toggle-content hidden">
<label class="button is-small" role="button" tabindex="0" for="include-spoilers-{{ uuid }}">Add spoilers/content warning</label>
</div>
</div>
<div>
<input type="radio" class="toggle-control" id="include-spoilers-{{ uuid }}" name="sensitive" value="true" {% if parent_status.content_warning %}checked{% endif %}>
<div class="toggle-content hidden">
<label class="button is-small" role="button" tabindex="0" for="hide-spoilers-{{ uuid }}">Remove spoilers/content warning</label>
<label class="is-sr-only" for="id_content_warning_{{ uuid }}">Spoilers/content warning:</label>
<input type="text" name="content_warning" maxlength="255" class="input" id="id_content_warning_{{ uuid }}" placeholder="Spoilers ahead!"{% if parent_status.content_warning %} value="{{ parent_status.content_warning }}"{% endif %}>
</div>
</div>
</div>
{% endwith %}

View file

@ -1,2 +0,0 @@
{% load bookwyrm_tags %}
'{{ book.title }}' Cover ({{ book|edition_info }})

View file

@ -26,6 +26,9 @@
</div> </div>
</fieldset> </fieldset>
{% endif %} {% endif %}
{% include 'snippets/content_warning_field.html' %}
{% if type == 'quote' %} {% if type == 'quote' %}
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required></textarea> <textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required></textarea>
{% else %} {% else %}

View file

@ -4,7 +4,9 @@
{% for i in '12345'|make_list %} {% for i in '12345'|make_list %}
<form name="rate" action="/rate/" method="POST" onsubmit="return rate_stars(event)"> <form name="rate" action="/rate/" method="POST" onsubmit="return rate_stars(event)">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="privacy" value="public">
<input type="hidden" name="rating" value="{{ forloop.counter }}"> <input type="hidden" name="rating" value="{{ forloop.counter }}">
<button type="submit" class="icon icon-star-{% if book|rating:user < forloop.counter %}empty{% else %}full{% endif %}"> <button type="submit" class="icon icon-star-{% if book|rating:user < forloop.counter %}empty{% else %}full{% endif %}">
<span class="is-sr-only">{{ forloop.counter }} star{{ forloop.counter | pluralize }}</span> <span class="is-sr-only">{{ forloop.counter }} star{{ forloop.counter | pluralize }}</span>

View file

@ -0,0 +1,80 @@
{% load humanize %}
<div class="content block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="show-readthrough-{{ readthrough.id }}" checked>
<div class="toggle-content hidden">
<dl>
{% if readthrough.start_date %}
<dt>Started reading:</dt>
<dd>{{ readthrough.start_date | naturalday }}</dd>
{% endif %}
{% if readthrough.finish_date %}
<dt>Finished reading:</dt>
<dd>{{ readthrough.finish_date | naturalday }}</dd>
{% endif %}
</dl>
<div class="field is-grouped">
<label class="button is-small" for="edit-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-pencil">
<span class="is-sr-only">Edit read-through dates</span>
</span>
</label>
<label class="button is-small" for="delete-readthrough-{{ readthrough.id }}" role="button" tabindex="0">
<span class="icon icon-x">
<span class="is-sr-only">Delete this read-through</span>
</span>
</label>
</div>
</div>
</div>
<div class="block">
<input class="toggle-control" type="radio" name="show-edit-readthrough" id="edit-readthrough-{{ readthrough.id }}">
<div class="toggle-content hidden">
<div class="box">
<form name="edit-readthrough" action="/edit-readthrough" method="post">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<div class="field">
<label class="label">
Started reading
<input type="date" name="start_date" class="input" id="id_start_date-{{ readthrough.id }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field">
<label class="label">
Finished reading
<input type="date" name="finish_date" class="input" id="id_finish_date-{{ readthrough.id }}" value="{{ readthrough.finish_date | date:"Y-m-d" }}">
</label>
</div>
<div class="field is-grouped">
<button class="button is-primary" type="submit">Save</button>
<label class="button" for="show-readthrough-{{ readthrough.id }}" role="button" tabindex="0">Cancel</label>
</div>
</form>
</div>
</div>
</div>
<div>
<input class="toggle-control" type="checkbox" name="delete-readthrough-{{ readthrough.id }}" id="delete-readthrough-{{ readthrough.id }}">
<div class="modal toggle-content hidden">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Delete this read-though?</p>
<label class="delete" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</header>
<footer class="modal-card-foot">
<form name="delete-readthrough-{{ readthrough.id }}" action="/delete-readthrough" method="POST">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<button class="button is-danger is-light" type="submit">
Delete
</button>
<label for="delete-readthrough-{{ readthrough.id }}" class="button" role="button" tabindex="0">Cancel</button>
</form>
</footer>
</div>
<label class="modal-close is-large" for="delete-readthrough-{{ readthrough.id }}" aria-label="close"></label>
</div>
</div>

View file

@ -6,11 +6,12 @@
<input type="hidden" name="reply_parent" value="{{ activity.id }}"> <input type="hidden" name="reply_parent" value="{{ activity.id }}">
<input type="hidden" name="user" value="{{ request.user.id }}"> <input type="hidden" name="user" value="{{ request.user.id }}">
<div class="column"> <div class="column">
{% include 'snippets/content_warning_field.html' with parent_status=activity %}
<div class="field"> <div class="field">
<textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ activity.id }}-{{ uuid }}" required="true"></textarea> <textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ activity.id }}-{{ uuid }}" required="true"></textarea>
</div> </div>
</div> </div>
<div class="column is-narrow"> <div class="column is-narrow">
<div class="field"> <div class="field">
{% include 'snippets/privacy_select.html' with current=activity.privacy %} {% include 'snippets/privacy_select.html' with current=activity.privacy %}

View file

@ -4,24 +4,28 @@
{% with book.id|uuid as uuid %} {% with book.id|uuid as uuid %}
{% active_shelf book as active_shelf %} {% active_shelf book as active_shelf %}
<div class="field is-grouped"> <div class="field is-grouped">
{% if active_shelf.identifier == 'read' %} {% if switch_mode and active_shelf.book != book %}
{% include 'snippets/switch_edition_button.html' with edition=book size='is-small' %}
{% else %}
{% if active_shelf.shelf.identifier == 'read' %}
<button class="button is-small" disabled> <button class="button is-small" disabled>
<span>Read</span> <span class="icon icon-check"></span> <span>Read</span> <span class="icon icon-check"></span>
</button> </button>
{% elif active_shelf.identifier == 'reading' %} {% elif active_shelf.shelf.identifier == 'reading' %}
<label class="button is-small" for="finish-reading-{{ uuid }}" role="button" tabindex="0"> <label class="button is-small" for="finish-reading-{{ uuid }}" role="button" tabindex="0">
I'm done! I'm done!
</label> </label>
{% include 'snippets/finish_reading_modal.html' %} {% include 'snippets/finish_reading_modal.html' with book=active_shelf.book %}
{% elif active_shelf.identifier == 'to-read' %} {% elif active_shelf.shelf.identifier == 'to-read' %}
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0"> <label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
Start reading Start reading
</label> </label>
{% include 'snippets/start_reading_modal.html' %} {% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
{% else %} {% else %}
<form name="shelve" action="/shelve/" method="post"> <form name="shelve" action="/shelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<input type="hidden" name="shelf" value="to-read"> <input type="hidden" name="shelf" value="to-read">
<button class="button is-small" type="submit">Want to read</button> <button class="button is-small" type="submit">Want to read</button>
</form> </form>
@ -40,17 +44,17 @@
<ul class="dropdown-content"> <ul class="dropdown-content">
{% for shelf in request.user.shelf_set.all %} {% for shelf in request.user.shelf_set.all %}
<li role="menuitem"> <li role="menuitem">
{% if shelf.identifier == 'to-read' %} {% if active_shelf.shelf.identifier == 'to-read' and shelf.identifier == 'reading' %}
<div class="dropdown-item pt-0 pb-0"> <div class="dropdown-item pt-0 pb-0">
<label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0"> <label class="button is-small" for="start-reading-{{ uuid }}" role="button" tabindex="0">
Start reading Start reading
</label> </label>
{% include 'snippets/start_reading_modal.html' %} {% include 'snippets/start_reading_modal.html' with book=active_shelf.book %}
</div> </div>
{% else %} {% else %}
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post"> <form class="dropdown-item pt-0 pb-0" name="shelve" action="/shelve/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<button class="button is-small" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}> <button class="button is-small" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>
<span>{{ shelf.name }}</span> <span>{{ shelf.name }}</span>
{% if shelf in book.shelf_set.all %}<span class="icon icon-check"></span>{% endif %} {% if shelf in book.shelf_set.all %}<span class="icon icon-check"></span>{% endif %}
@ -62,6 +66,7 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View file

@ -1,38 +1,57 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
<div class="block"> <div class="block">
{% if status.status_type == 'Review' %} {% if status.status_type == 'Review' %}
<h3> <div>
{% if status.name %}{{ status.name }}<br>{% endif %} <h3 class="title is-5 has-subtitle">
{% include 'snippets/stars.html' with rating=status.rating %} {% if status.name %}{{ status.name }}<br>{% endif %}
</h3> </h3>
{% endif %} <p class="subtitle">{% include 'snippets/stars.html' with rating=status.rating %}</p>
{% if status.quote %}
<div class="quote block">
<blockquote>{{ status.quote }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div> </div>
{% endif %} {% endif %}
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %} {% if status.content_warning %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe %} <div class="toggle-content">
{% endif %} <p>{{ status.content_warning }}</p>
{% if status.attachments %} <input class="toggle-control" type="radio" name="toggle-status-cw-{{ status.id }}" id="hide-status-cw-{{ status.id }}" checked>
<div class="block"> <div class="toggle-content hidden">
<div class="columns"> <label class="button is-small" for="show-status-cw-{{ status.id }}" tabindex="0" role="button">Show More</label>
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="open image in new window">
<img src="/images/{{ attachment.image }}" alt="{{ attachment.caption }}">
</a>
</figure>
</div>
{% endfor %}
</div> </div>
</div> </div>
<input class="toggle-control" type="radio" name="toggle-status-cw-{{ status.id }}" id="show-status-cw-{{ status.id }}">
{% endif %} {% endif %}
<div{% if status.content_warning %} class="toggle-content hidden"{% endif %}>
{% if status.content_warning %}
<label class="button is-small" for="hide-status-cw-{{ status.id }}" tabindex="0" role="button">Show Less</label>
{% endif %}
{% if status.quote %}
<div class="quote block">
<blockquote>{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div>
{% endif %}
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe %}
{% endif %}
{% if status.attachments %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="open image in new window">
<img src="/images/{{ attachment.image }}" alt="{{ attachment.caption }}">
</a>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div> </div>
{% if not hide_book %} {% if not hide_book %}

View file

@ -0,0 +1,5 @@
<form name="switch-edition" action="/switch-edition" method="POST">
{% csrf_token %}
<input type="hidden" name="edition" value="{{ edition.id }}">
<button class="button {{ size }}">Switch to this edition</button>
</form>

View file

@ -6,18 +6,18 @@
{% if trimmed != full %} {% if trimmed != full %}
<div> <div>
<input type="radio" name="show-hide-{{ uuid }}" id="show-{{ uuid }}" class="toggle-control" checked> <input type="radio" name="show-hide-{{ uuid }}" id="show-{{ uuid }}" class="toggle-control" checked>
<blockquote class="content toggle-content hidden">{{ trimmed }} <blockquote class="content toggle-content hidden">{{ trimmed | to_markdown | safe }}
<label class="button is-small" for="hide-{{ uuid }}"><div role="button" tabindex="0">show more</div></label> <label class="button is-small" for="hide-{{ uuid }}"><div role="button" tabindex="0">show more</div></label>
</blockquote> </blockquote>
</div> </div>
<div> <div>
<input type="radio" name="show-hide-{{ uuid }}" id="hide-{{ uuid }}" class="toggle-control"> <input type="radio" name="show-hide-{{ uuid }}" id="hide-{{ uuid }}" class="toggle-control">
<blockquote class="content toggle-content hidden">{{ full }} <blockquote class="content toggle-content hidden">{{ full | to_markdown | safe }}
<label class="button is-small" for="show-{{ uuid }}"><div role="button" tabindex="0">show less</div></label> <label class="button is-small" for="show-{{ uuid }}"><div role="button" tabindex="0">show less</div></label>
</blockquote> </blockquote>
</div> </div>
{% else %} {% else %}
<blockquote class="content">{{ full }}</blockquote> <blockquote class="content">{{ full | to_markdown | safe }}</blockquote>
{% endif %} {% endif %}
{% endwith %} {% endwith %}

View file

@ -23,7 +23,14 @@
<div class="column"> <div class="column">
{% if user.summary %} {% if user.summary %}
<blockquote><span class="icon icon-quote-open"></span>{{ user.summary | safe }}</blockquote> <div class="columns">
<div class="column is-narrow">
<span class="icon icon-quote-open"></span>
</div>
<div class="column">
<blockquote>{{ user.summary | to_markdown | safe }}</blockquote>
</div>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View file

@ -7,6 +7,7 @@ from django import template
from django.utils import timezone from django.utils import timezone
from bookwyrm import models from bookwyrm import models
from bookwyrm.outgoing import to_markdown
register = template.Library() register = template.Library()
@ -97,20 +98,6 @@ def get_boosted(boost):
).get() ).get()
@register.filter(name='edition_info')
def get_edition_info(book):
''' paperback, French language, 1982 '''
if not book:
return ''
items = [
book.physical_format if isinstance(book, models.Edition) else None,
book.languages[0] + ' language' if book.languages and \
book.languages[0] != 'English' else None,
str(book.published_date.year) if book.published_date else None,
]
return ', '.join(i for i in items if i)
@register.filter(name='book_description') @register.filter(name='book_description')
def get_book_description(book): def get_book_description(book):
''' use the work's text if the book doesn't have it ''' ''' use the work's text if the book doesn't have it '''
@ -132,9 +119,10 @@ def time_since(date):
delta = now - date delta = now - date
if date < (now - relativedelta(weeks=1)): if date < (now - relativedelta(weeks=1)):
formatter = '%b %-d'
if date.year != now.year: if date.year != now.year:
return date.strftime('%b %-d %Y') formatter += ' %Y'
return date.strftime('%b %-d') return date.strftime(formatter)
delta = relativedelta(now, date) delta = relativedelta(now, date)
if delta.days: if delta.days:
return '%dd' % delta.days return '%dd' % delta.days
@ -145,14 +133,21 @@ def time_since(date):
return '%ds' % delta.seconds return '%ds' % delta.seconds
@register.filter(name="to_markdown")
def get_markdown(content):
''' convert markdown to html '''
if content:
return to_markdown(content)
return None
@register.simple_tag(takes_context=True) @register.simple_tag(takes_context=True)
def active_shelf(context, book): def active_shelf(context, book):
''' check what shelf a user has a book on, if any ''' ''' check what shelf a user has a book on, if any '''
shelf = models.ShelfBook.objects.filter( shelf = models.ShelfBook.objects.filter(
shelf__user=context['request'].user, shelf__user=context['request'].user,
book=book book__in=book.parent_work.editions.all()
).first() ).first()
return shelf.shelf if shelf else None return shelf if shelf else {'book': book}
@register.simple_tag(takes_context=False) @register.simple_tag(takes_context=False)

View file

@ -103,7 +103,7 @@ class BaseActivity(TestCase):
def test_to_model_simple_fields(self): def test_to_model_simple_fields(self):
''' test setting simple fields ''' ''' test setting simple fields '''
self.assertEqual(self.user.name, '') self.assertIsNone(self.user.name)
activity = activitypub.Person( activity = activitypub.Person(
id=self.user.remote_id, id=self.user.remote_id,

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):
@ -43,7 +44,7 @@ class Openlibrary(TestCase):
def test_pick_default_edition(self): def test_pick_default_edition(self):
edition = pick_default_edition(self.edition_list_data['entries']) edition = pick_default_edition(self.edition_list_data['entries'])
self.assertEqual(edition['key'], '/books/OL9952943M') self.assertEqual(edition['key'], '/books/OL9788823M')
def test_format_search_result(self): def test_format_search_result(self):
@ -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

@ -25,12 +25,13 @@ class SelfConnector(TestCase):
self.work = models.Work.objects.create( self.work = models.Work.objects.create(
title='Example Work', title='Example Work',
) )
author = models.Author.objects.create(name='Anonymous')
self.edition = models.Edition.objects.create( self.edition = models.Edition.objects.create(
title='Edition of Example Work', title='Edition of Example Work',
author_text='Anonymous',
published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc), published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc),
parent_work=self.work, parent_work=self.work,
) )
self.edition.authors.add(author)
models.Edition.objects.create( models.Edition.objects.create(
title='Another Edition', title='Another Edition',
parent_work=self.work, parent_work=self.work,
@ -41,11 +42,12 @@ class SelfConnector(TestCase):
subtitle='The Anonymous Edition', subtitle='The Anonymous Edition',
parent_work=self.work, parent_work=self.work,
) )
models.Edition.objects.create(
edition = models.Edition.objects.create(
title='An Edition', title='An Edition',
author_text='Fish',
parent_work=self.work parent_work=self.work
) )
edition.authors.add(models.Author.objects.create(name='Fish'))
def test_format_search_result(self): def test_format_search_result(self):

View file

@ -1,5 +1,7 @@
''' testing models ''' ''' testing models '''
from dateutil.parser import parse
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
from bookwyrm.models.book import isbn_10_to_13, isbn_13_to_10 from bookwyrm.models.book import isbn_10_to_13, isbn_13_to_10
@ -56,3 +58,27 @@ class Book(TestCase):
isbn_13 = '978-1788-16167-1' isbn_13 = '978-1788-16167-1'
isbn_10 = isbn_13_to_10(isbn_13) isbn_10 = isbn_13_to_10(isbn_13)
self.assertEqual(isbn_10, '178816167X') self.assertEqual(isbn_10, '178816167X')
def test_get_edition_info(self):
''' text slug about an edition '''
book = models.Edition.objects.create(title='Test Edition')
self.assertEqual(book.edition_info, '')
book.physical_format = 'worm'
book.save()
self.assertEqual(book.edition_info, 'worm')
book.languages = ['English']
book.save()
self.assertEqual(book.edition_info, 'worm')
book.languages = ['Glorbish', 'English']
book.save()
self.assertEqual(book.edition_info, 'worm, Glorbish language')
book.published_date = timezone.make_aware(parse('2020'))
book.save()
self.assertEqual(book.edition_info, 'worm, Glorbish language, 2020')
self.assertEqual(
book.alt_text, 'Test Edition cover (worm, Glorbish language, 2020)')

View file

@ -21,31 +21,23 @@ from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm.models import fields, User, Status from bookwyrm.models import fields, User, Status
from bookwyrm.models.base_model import ActivitypubMixin, BookWyrmModel from bookwyrm.models.base_model import ActivitypubMixin, BookWyrmModel
#pylint: disable=too-many-public-methods
class ActivitypubFields(TestCase): class ActivitypubFields(TestCase):
''' overwrites standard model feilds to work with activitypub ''' ''' overwrites standard model feilds to work with activitypub '''
def test_validate_remote_id(self): def test_validate_remote_id(self):
''' should look like a url ''' ''' should look like a url '''
self.assertIsNone(fields.validate_remote_id( self.assertIsNone(fields.validate_remote_id('http://www.example.com'))
'http://www.example.com' self.assertIsNone(fields.validate_remote_id('https://www.example.com'))
)) self.assertIsNone(fields.validate_remote_id('http://exle.com/dlg-23/x'))
self.assertIsNone(fields.validate_remote_id(
'https://www.example.com'
))
self.assertIsNone(fields.validate_remote_id(
'http://example.com/dlfjg-23/x'
))
self.assertRaises( self.assertRaises(
ValidationError, fields.validate_remote_id, ValidationError, fields.validate_remote_id,
'http:/example.com/dlfjg-23/x' 'http:/example.com/dlfjg-23/x')
)
self.assertRaises( self.assertRaises(
ValidationError, fields.validate_remote_id, ValidationError, fields.validate_remote_id,
'www.example.com/dlfjg-23/x' 'www.example.com/dlfjg-23/x')
)
self.assertRaises( self.assertRaises(
ValidationError, fields.validate_remote_id, ValidationError, fields.validate_remote_id,
'http://www.example.com/dlfjg 23/x' 'http://www.example.com/dlfjg 23/x')
)
def test_activitypub_field_mixin(self): def test_activitypub_field_mixin(self):
''' generic mixin with super basic to and from functionality ''' ''' generic mixin with super basic to and from functionality '''
@ -71,6 +63,38 @@ class ActivitypubFields(TestCase):
instance.name = 'snake_case_name' instance.name = 'snake_case_name'
self.assertEqual(instance.get_activitypub_field(), 'snakeCaseName') self.assertEqual(instance.get_activitypub_field(), 'snakeCaseName')
def test_set_field_from_activity(self):
''' setter from entire json blob '''
@dataclass
class TestModel:
''' real simple mock '''
field_name: str
mock_model = TestModel(field_name='bip')
TestActivity = namedtuple('test', ('fieldName', 'unrelated'))
data = TestActivity(fieldName='hi', unrelated='bfkjh')
instance = fields.ActivitypubFieldMixin()
instance.name = 'field_name'
instance.set_field_from_activity(mock_model, data)
self.assertEqual(mock_model.field_name, 'hi')
def test_set_activity_from_field(self):
''' set json field given entire model '''
@dataclass
class TestModel:
''' real simple mock '''
field_name: str
unrelated: str
mock_model = TestModel(field_name='bip', unrelated='field')
instance = fields.ActivitypubFieldMixin()
instance.name = 'field_name'
data = {}
instance.set_activity_from_field(data, mock_model)
self.assertEqual(data['fieldName'], 'bip')
def test_remote_id_field(self): def test_remote_id_field(self):
''' just sets some defaults on charfield ''' ''' just sets some defaults on charfield '''
instance = fields.RemoteIdField() instance = fields.RemoteIdField()
@ -369,17 +393,19 @@ class ActivitypubFields(TestCase):
ContentFile(output.getvalue()) ContentFile(output.getvalue())
) )
output = fields.image_serializer(user.avatar) output = fields.image_serializer(user.avatar, alt='alt text')
self.assertIsNotNone( self.assertIsNotNone(
re.match( re.match(
r'.*\.jpg', r'.*\.jpg',
output.url, output.url,
) )
) )
self.assertEqual(output.name, 'alt text')
self.assertEqual(output.type, 'Image') self.assertEqual(output.type, 'Image')
instance = fields.ImageField() instance = fields.ImageField()
output = fields.image_serializer(user.avatar, alt=None)
self.assertEqual(instance.field_to_activity(user.avatar), output) self.assertEqual(instance.field_to_activity(user.avatar), output)
responses.add( responses.add(
@ -408,3 +434,12 @@ class ActivitypubFields(TestCase):
''' idk why it makes them strings but probably for a good reason ''' ''' idk why it makes them strings but probably for a good reason '''
instance = fields.ArrayField(fields.IntegerField) instance = fields.ArrayField(fields.IntegerField)
self.assertEqual(instance.field_to_activity([0, 1]), ['0', '1']) self.assertEqual(instance.field_to_activity([0, 1]), ['0', '1'])
def test_html_field(self):
''' sanitizes html, the sanitizer has its own tests '''
instance = fields.HtmlField()
self.assertEqual(
instance.field_from_activity('<marquee><p>hi</p></marquee>'),
'<p>hi</p>'
)

View file

@ -1,42 +1,274 @@
''' 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'],
'test content<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'],
'a sickening sense <p>-- <a href="%s">"Test Edition"</a></p>' \
'test content' % 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

@ -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()
@ -433,13 +436,35 @@ class Incoming(TestCase):
boosted_status=self.status, user=self.remote_user) boosted_status=self.status, user=self.remote_user)
incoming.handle_unboost(activity) incoming.handle_unboost(activity)
def test_handle_add_book(self):
''' shelving a book '''
book = models.Edition.objects.create(
title='Test', remote_id='https://bookwyrm.social/book/37292')
shelf = models.Shelf.objects.create(
user=self.remote_user, name='Test Shelf')
shelf.remote_id = 'https://bookwyrm.social/user/mouse/shelf/to-read'
shelf.save()
activity = {
"id": "https://bookwyrm.social/shelfbook/6189#add",
"type": "Add",
"actor": "hhttps://example.com/users/rat",
"object": "https://bookwyrm.social/book/37292",
"target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams"
}
incoming.handle_add(activity)
self.assertEqual(shelf.books.first(), book)
def test_handle_update_user(self): def test_handle_update_user(self):
''' update an existing user ''' ''' update an existing user '''
datafile = pathlib.Path(__file__).parent.joinpath( datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json') 'data/ap_user.json')
userdata = json.loads(datafile.read_bytes()) userdata = json.loads(datafile.read_bytes())
del userdata['icon'] del userdata['icon']
self.assertEqual(self.local_user.name, '') self.assertIsNone(self.local_user.name)
incoming.handle_update_user({'object': userdata}) incoming.handle_update_user({'object': userdata})
user = models.User.objects.get(id=self.local_user.id) user = models.User.objects.get(id=self.local_user.id)
self.assertEqual(user.name, 'MOUSE?? MOUSE!!') self.assertEqual(user.name, 'MOUSE?? MOUSE!!')
@ -451,11 +476,14 @@ class Incoming(TestCase):
'data/fr_edition.json') 'data/fr_edition.json')
bookdata = json.loads(datafile.read_bytes()) bookdata = json.loads(datafile.read_bytes())
models.Work.objects.create(
title='Test Work', remote_id='https://bookwyrm.social/book/5988')
book = models.Edition.objects.create( book = models.Edition.objects.create(
title='Test Book', remote_id='https://bookwyrm.social/book/5989') title='Test Book', remote_id='https://bookwyrm.social/book/5989')
del bookdata['authors'] del bookdata['authors']
self.assertEqual(book.title, 'Test Book') self.assertEqual(book.title, 'Test Book')
with patch( with patch(
'bookwyrm.activitypub.base_activity.set_related_field.delay'): 'bookwyrm.activitypub.base_activity.set_related_field.delay'):
incoming.handle_update_edition({'object': bookdata}) incoming.handle_update_edition({'object': bookdata})

View file

@ -1,34 +1,36 @@
''' make sure only valid html gets to the app '''
from django.test import TestCase from django.test import TestCase
from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.sanitize_html import InputHtmlParser
class Sanitizer(TestCase): class Sanitizer(TestCase):
''' sanitizer tests '''
def test_no_html(self): def test_no_html(self):
''' just text '''
input_text = 'no html ' input_text = 'no html '
parser = InputHtmlParser() parser = InputHtmlParser()
parser.feed(input_text) parser.feed(input_text)
output = parser.get_output() output = parser.get_output()
self.assertEqual(input_text, output) self.assertEqual(input_text, output)
def test_valid_html(self): def test_valid_html(self):
''' leave the html untouched '''
input_text = '<b>yes </b> <i>html</i>' input_text = '<b>yes </b> <i>html</i>'
parser = InputHtmlParser() parser = InputHtmlParser()
parser.feed(input_text) parser.feed(input_text)
output = parser.get_output() output = parser.get_output()
self.assertEqual(input_text, output) self.assertEqual(input_text, output)
def test_valid_html_attrs(self): def test_valid_html_attrs(self):
''' and don't remove attributes '''
input_text = '<a href="fish.com">yes </a> <i>html</i>' input_text = '<a href="fish.com">yes </a> <i>html</i>'
parser = InputHtmlParser() parser = InputHtmlParser()
parser.feed(input_text) parser.feed(input_text)
output = parser.get_output() output = parser.get_output()
self.assertEqual(input_text, output) self.assertEqual(input_text, output)
def test_invalid_html(self): def test_invalid_html(self):
''' remove all html when the html is malformed '''
input_text = '<b>yes <i>html</i>' input_text = '<b>yes <i>html</i>'
parser = InputHtmlParser() parser = InputHtmlParser()
parser.feed(input_text) parser.feed(input_text)
@ -41,8 +43,8 @@ class Sanitizer(TestCase):
output = parser.get_output() output = parser.get_output()
self.assertEqual('yes html ', output) self.assertEqual('yes html ', output)
def test_disallowed_html(self): def test_disallowed_html(self):
''' remove disallowed html but keep allowed html '''
input_text = '<div> yes <i>html</i></div>' input_text = '<div> yes <i>html</i></div>'
parser = InputHtmlParser() parser = InputHtmlParser()
parser.feed(input_text) parser.feed(input_text)

View file

@ -158,34 +158,6 @@ class TemplateTags(TestCase):
self.assertEqual(boosted, status) self.assertEqual(boosted, status)
def test_get_edition_info(self):
''' text slug about an edition '''
self.assertEqual(
bookwyrm_tags.get_edition_info(self.book), '')
self.book.physical_format = 'worm'
self.book.save()
self.assertEqual(
bookwyrm_tags.get_edition_info(self.book), 'worm')
self.book.languages = ['English']
self.book.save()
self.assertEqual(
bookwyrm_tags.get_edition_info(self.book), 'worm')
self.book.languages = ['Glorbish', 'English']
self.book.save()
self.assertEqual(
bookwyrm_tags.get_edition_info(self.book),
'worm, Glorbish language')
self.book.published_date = timezone.make_aware(parse('2020'))
self.book.save()
self.assertEqual(
bookwyrm_tags.get_edition_info(self.book),
'worm, Glorbish language, 2020')
def test_get_book_description(self): def test_get_book_description(self):
''' grab it from the edition or the parent ''' ''' grab it from the edition or the parent '''
work = models.Work.objects.create(title='Test Work') work = models.Work.objects.create(title='Test Work')

View file

@ -0,0 +1,264 @@
''' test for app action functionality '''
from unittest.mock import patch
from django.core.exceptions import PermissionDenied
from django.http.response import Http404
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import view_actions as actions, models
from bookwyrm.settings import DOMAIN
#pylint: disable=too-many-public-methods
class ViewActions(TestCase):
''' a lot here: all handlers for receiving activitypub requests '''
def setUp(self):
''' we need basic things, like users '''
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', local=True)
self.local_user.remote_id = 'https://example.com/user/mouse'
self.local_user.save()
with patch('bookwyrm.models.user.set_remote_server.delay'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.status = models.Status.objects.create(
user=self.local_user,
content='Test status',
remote_id='https://example.com/status/1',
)
self.settings = models.SiteSettings.objects.create(id=1)
self.factory = RequestFactory()
def test_register(self):
''' create a user '''
self.assertEqual(models.User.objects.count(), 2)
request = self.factory.post(
'register/',
{
'username': 'nutria-user.user_nutria',
'password': 'mouseword',
'email': 'aa@bb.cccc'
})
with patch('bookwyrm.view_actions.login'):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, 'nutria-user.user_nutria@%s' % DOMAIN)
self.assertEqual(nutria.localname, 'nutria-user.user_nutria')
self.assertEqual(nutria.local, True)
def test_register_trailing_space(self):
''' django handles this so weirdly '''
request = self.factory.post(
'register/',
{
'username': 'nutria ',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
with patch('bookwyrm.view_actions.login'):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
self.assertEqual(response.status_code, 302)
nutria = models.User.objects.last()
self.assertEqual(nutria.username, 'nutria@%s' % DOMAIN)
self.assertEqual(nutria.localname, 'nutria')
self.assertEqual(nutria.local, True)
def test_register_invalid_email(self):
''' gotta have an email '''
self.assertEqual(models.User.objects.count(), 2)
request = self.factory.post(
'register/',
{
'username': 'nutria',
'password': 'mouseword',
'email': 'aa'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
def test_register_invalid_username(self):
''' gotta have an email '''
self.assertEqual(models.User.objects.count(), 2)
request = self.factory.post(
'register/',
{
'username': 'nut@ria',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
request = self.factory.post(
'register/',
{
'username': 'nutr ia',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
request = self.factory.post(
'register/',
{
'username': 'nut@ria',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 2)
self.assertEqual(response.template_name, 'login.html')
def test_register_closed_instance(self):
''' you can't just register '''
self.settings.allow_registration = False
self.settings.save()
request = self.factory.post(
'register/',
{
'username': 'nutria ',
'password': 'mouseword',
'email': 'aa@bb.ccc'
})
with self.assertRaises(PermissionDenied):
actions.register(request)
def test_register_invite(self):
''' you can't just register '''
self.settings.allow_registration = False
self.settings.save()
models.SiteInvite.objects.create(
code='testcode', user=self.local_user, use_limit=1)
self.assertEqual(models.SiteInvite.objects.get().times_used, 0)
request = self.factory.post(
'register/',
{
'username': 'nutria',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'testcode'
})
with patch('bookwyrm.view_actions.login'):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
self.assertEqual(response.status_code, 302)
self.assertEqual(models.SiteInvite.objects.get().times_used, 1)
# invalid invite
request = self.factory.post(
'register/',
{
'username': 'nutria2',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'testcode'
})
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
# bad invite code
request = self.factory.post(
'register/',
{
'username': 'nutria3',
'password': 'mouseword',
'email': 'aa@bb.ccc',
'invite_code': 'dkfkdjgdfkjgkdfj'
})
with self.assertRaises(Http404):
response = actions.register(request)
self.assertEqual(models.User.objects.count(), 3)
def test_password_reset_request(self):
''' send 'em an email '''
request = self.factory.post('', {'email': 'aa@bb.ccc'})
resp = actions.password_reset_request(request)
self.assertEqual(resp.status_code, 302)
request = self.factory.post(
'', {'email': 'mouse@mouse.com'})
with patch('bookwyrm.emailing.send_email.delay'):
resp = actions.password_reset_request(request)
self.assertEqual(resp.template_name, 'password_reset_request.html')
self.assertEqual(
models.PasswordReset.objects.get().user, self.local_user)
def test_password_reset(self):
''' reset from code '''
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post('', {
'reset-code': code.code,
'password': 'hi',
'confirm-password': 'hi'
})
with patch('bookwyrm.view_actions.login'):
resp = actions.password_reset(request)
self.assertEqual(resp.status_code, 302)
self.assertFalse(models.PasswordReset.objects.exists())
def test_password_reset_wrong_code(self):
''' reset from code '''
models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post('', {
'reset-code': 'jhgdkfjgdf',
'password': 'hi',
'confirm-password': 'hi'
})
resp = actions.password_reset(request)
self.assertEqual(resp.template_name, 'password_reset.html')
self.assertTrue(models.PasswordReset.objects.exists())
def test_password_reset_mismatch(self):
''' reset from code '''
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.post('', {
'reset-code': code.code,
'password': 'hi',
'confirm-password': 'hihi'
})
resp = actions.password_reset(request)
self.assertEqual(resp.template_name, 'password_reset.html')
self.assertTrue(models.PasswordReset.objects.exists())
def test_switch_edition(self):
''' updates user's relationships to a book '''
work = models.Work.objects.create(title='test work')
edition1 = models.Edition.objects.create(
title='first ed', parent_work=work)
edition2 = models.Edition.objects.create(
title='second ed', parent_work=work)
shelf = models.Shelf.objects.create(
name='Test Shelf', user=self.local_user)
shelf.books.add(edition1)
models.ReadThrough.objects.create(
user=self.local_user, book=edition1)
self.assertEqual(models.ShelfBook.objects.get().book, edition1)
self.assertEqual(models.ReadThrough.objects.get().book, edition1)
request = self.factory.post('', {
'edition': edition2.id
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.switch_edition(request)
self.assertEqual(models.ShelfBook.objects.get().book, edition2)
self.assertEqual(models.ReadThrough.objects.get().book, edition2)

View file

@ -98,15 +98,16 @@ urlpatterns = [
re_path(r'^edit-profile/?$', actions.edit_profile), re_path(r'^edit-profile/?$', actions.edit_profile),
re_path(r'^import-data/?', actions.import_data), re_path(r'^import-data/?$', actions.import_data),
re_path(r'^retry-import/?', actions.retry_import), re_path(r'^retry-import/?$', actions.retry_import),
re_path(r'^resolve-book/?', actions.resolve_book), re_path(r'^resolve-book/?$', actions.resolve_book),
re_path(r'^edit-book/(?P<book_id>\d+)/?', actions.edit_book), re_path(r'^edit-book/(?P<book_id>\d+)/?$', actions.edit_book),
re_path(r'^upload-cover/(?P<book_id>\d+)/?', actions.upload_cover), re_path(r'^upload-cover/(?P<book_id>\d+)/?$', actions.upload_cover),
re_path(r'^add-description/(?P<book_id>\d+)/?', actions.add_description), re_path(r'^add-description/(?P<book_id>\d+)/?$', actions.add_description),
re_path(r'^edit-readthrough/?', actions.edit_readthrough), re_path(r'^switch-edition/?$', actions.switch_edition),
re_path(r'^delete-readthrough/?', actions.delete_readthrough), re_path(r'^edit-readthrough/?$', actions.edit_readthrough),
re_path(r'^delete-readthrough/?$', actions.delete_readthrough),
re_path(r'^rate/?$', actions.rate), re_path(r'^rate/?$', actions.rate),
re_path(r'^review/?$', actions.review), re_path(r'^review/?$', actions.review),

View file

@ -10,15 +10,15 @@ from django.contrib.auth import authenticate, login, logout
from django.contrib.auth.decorators import login_required, permission_required from django.contrib.auth.decorators import login_required, permission_required
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.db import transaction
from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST from django.views.decorators.http import require_GET, require_POST
from bookwyrm import books_manager from bookwyrm import books_manager, forms, models, outgoing, goodreads_import
from bookwyrm import forms, models, outgoing from bookwyrm.broadcast import broadcast
from bookwyrm import goodreads_import
from bookwyrm.emailing import password_reset_email from bookwyrm.emailing import password_reset_email
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.views import get_user_from_username from bookwyrm.views import get_user_from_username
@ -66,7 +66,7 @@ def register(request):
if not form.is_valid(): if not form.is_valid():
errors = True errors = True
username = form.data['username'] username = form.data['username'].strip()
email = form.data['email'] email = form.data['email']
password = form.data['password'] password = form.data['password']
@ -215,11 +215,14 @@ def edit_profile(request):
return redirect('/user/%s' % request.user.localname) return redirect('/user/%s' % request.user.localname)
@require_POST
def resolve_book(request): def resolve_book(request):
''' figure out the local path to a book from a remote_id ''' ''' figure out the local path to a book from a remote_id '''
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)
@ -239,12 +242,42 @@ def edit_book(request, book_id):
'form': form 'form': form
} }
return TemplateResponse(request, 'edit_book.html', data) return TemplateResponse(request, 'edit_book.html', data)
form.save() book = form.save()
outgoing.handle_update_book(request.user, book) outgoing.handle_update_book(request.user, book)
return redirect('/book/%s' % book.id) return redirect('/book/%s' % book.id)
@login_required
@require_POST
@transaction.atomic
def switch_edition(request):
''' switch your copy of a book to a different edition '''
edition_id = request.POST.get('edition')
new_edition = get_object_or_404(models.Edition, id=edition_id)
shelfbooks = models.ShelfBook.objects.filter(
book__parent_work=new_edition.parent_work,
shelf__user=request.user
)
for shelfbook in shelfbooks.all():
broadcast(request.user, shelfbook.to_remove_activity(request.user))
shelfbook.book = new_edition
shelfbook.save()
broadcast(request.user, shelfbook.to_add_activity(request.user))
readthroughs = models.ReadThrough.objects.filter(
book__parent_work=new_edition.parent_work,
user=request.user
)
for readthrough in readthroughs.all():
readthrough.book = new_edition
readthrough.save()
return redirect('/book/%d' % new_edition.id)
@login_required @login_required
@require_POST @require_POST
def upload_cover(request, book_id): def upload_cover(request, book_id):

View file

@ -594,8 +594,7 @@ def book_page(request, book_id):
prev_page = '/book/%s/?page=%d' % \ prev_page = '/book/%s/?page=%d' % \
(book_id, reviews_page.previous_page_number()) (book_id, reviews_page.previous_page_number())
user_tags = [] user_tags = readthroughs = user_shelves = other_edition_shelves = []
readthroughs = []
if request.user.is_authenticated: if request.user.is_authenticated:
user_tags = models.UserTag.objects.filter( user_tags = models.UserTag.objects.filter(
book=book, user=request.user book=book, user=request.user
@ -606,19 +605,26 @@ def book_page(request, book_id):
book=book, book=book,
).order_by('start_date') ).order_by('start_date')
rating = reviews.aggregate(Avg('rating')) user_shelves = models.ShelfBook.objects.filter(
tags = models.UserTag.objects.filter( added_by=request.user, book=book
book=book, )
)
other_edition_shelves = models.ShelfBook.objects.filter(
~Q(book=book),
added_by=request.user,
book__parent_work=book.parent_work,
)
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,
'other_edition_shelves': other_edition_shelves,
'readthroughs': readthroughs, 'readthroughs': readthroughs,
'path': '/book/%s' % book_id, 'path': '/book/%s' % book_id,
'info_fields': [ 'info_fields': [
@ -662,10 +668,9 @@ def editions_page(request, book_id):
encoder=ActivityEncoder encoder=ActivityEncoder
) )
editions = models.Edition.objects.filter(parent_work=work).all()
data = { data = {
'title': 'Editions of %s' % work.title, 'title': 'Editions of %s' % work.title,
'editions': editions, 'editions': work.editions.all(),
'work': work, 'work': work,
} }
return TemplateResponse(request, 'editions.html', data) return TemplateResponse(request, 'editions.html', data)
@ -679,7 +684,8 @@ def author_page(request, author_id):
if is_api_request(request): if is_api_request(request):
return JsonResponse(author.to_activity(), encoder=ActivityEncoder) return JsonResponse(author.to_activity(), encoder=ActivityEncoder)
books = models.Work.objects.filter(authors=author) books = models.Work.objects.filter(
Q(authors=author) | Q(editions__authors=author)).distinct()
data = { data = {
'title': author.name, 'title': author.name,
'author': author, 'author': author,
@ -751,7 +757,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(),

3
bw-dev
View file

@ -57,7 +57,8 @@ case "$1" in
clean clean
;; ;;
makemigrations) makemigrations)
execweb python manage.py makemigrations shift 1
execweb python manage.py makemigrations "$@"
;; ;;
migrate) migrate)
execweb python manage.py rename_app fedireads bookwyrm execweb python manage.py rename_app fedireads bookwyrm

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