diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 6401bb89..c344c120 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -63,7 +63,6 @@ class ActivityObject: setattr(self, field.name, value) - @transaction.atomic def to_model(self, model, instance=None, save=True): ''' convert from an activity to a model instance ''' if not isinstance(self, model.activity_serializer): @@ -74,6 +73,9 @@ class ActivityObject: model.activity_serializer) ) + if hasattr(model, 'ignore_activity') and model.ignore_activity(self): + return instance + # check for an existing instance, if we're not updating a known obj instance = instance or model.find_existing(self.serialize()) or model() @@ -88,13 +90,14 @@ class ActivityObject: if not save: return instance - # we can't set many to many and reverse fields on an unsaved object - instance.save() + with transaction.atomic(): + # 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 - for field in instance.many_to_many_fields: - # mention books/users, for example - field.set_field_from_activity(instance, self) + # add many to many fields, which have to be set post-save + for field in instance.many_to_many_fields: + # mention books/users, for example + field.set_field_from_activity(instance, self) # reversed relationships in the models for (model_field_name, activity_field_name) in \ @@ -103,20 +106,23 @@ class ActivityObject: values = getattr(self, activity_field_name) if values is None or values is MISSING: continue + + model_field = getattr(model, model_field_name) try: # 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: # it's a one to one or foreign key - related_model = getattr(model, model_field_name)\ - .related.related_model + related_model = model_field.related.related_model + related_field_name = model_field.related.related_name values = [values] for item in values: set_related_field.delay( related_model.__name__, instance.__class__.__name__, - instance.__class__.__name__.lower(), + related_field_name, instance.remote_id, item ) @@ -142,23 +148,25 @@ def set_related_field( require_ready=True ) - if isinstance(data, str): - item = resolve_remote_id(model, data, save=False) - else: - # look for a match based on all the available data - item = model.find_existing(data) - if not item: - # create a new model instance - item = model.activity_serializer(**data) - item = item.to_model(model, save=False) - # this must exist because it's the object that triggered this function - instance = origin_model.find_existing_by_remote_id(related_remote_id) - if not instance: - raise ValueError('Invalid related remote id: %s' % related_remote_id) + with transaction.atomic(): + if isinstance(data, str): + item = resolve_remote_id(model, data, save=False) + else: + # look for a match based on all the available data + item = model.find_existing(data) + if not item: + # create a new model instance + item = model.activity_serializer(**data) + item = item.to_model(model, save=False) + # this must exist because it's the object that triggered this function + instance = origin_model.find_existing_by_remote_id(related_remote_id) + if not instance: + raise ValueError( + 'Invalid related remote id: %s' % related_remote_id) - # edition.parent_work = instance, for example - setattr(item, related_field_name, instance) - item.save() + # edition.parent_work = instance, for example + setattr(item, related_field_name, instance) + item.save() def resolve_remote_id(model, remote_id, refresh=False, save=True): diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index ae9c334d..ee4b8851 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -38,7 +38,7 @@ class Edition(Book): isbn13: str = '' oclcNumber: str = '' asin: str = '' - pages: str = '' + pages: int = None physicalFormat: str = '' publishers: List[str] = field(default_factory=lambda: []) @@ -50,7 +50,7 @@ class Work(Book): ''' work instance of a book object ''' lccn: str = '' defaultEdition: str = '' - editions: List[str] + editions: List[str] = field(default_factory=lambda: []) type: str = 'Work' @@ -58,9 +58,9 @@ class Work(Book): class Author(ActivityObject): ''' author of a book ''' name: str - born: str = '' - died: str = '' - aliases: str = '' + born: str = None + died: str = None + aliases: List[str] = field(default_factory=lambda: []) bio: str = '' openlibraryKey: str = '' wikipediaLink: str = '' diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index df28bf8d..72fbe5fc 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -23,6 +23,7 @@ class Note(ActivityObject): cc: List[str] = field(default_factory=lambda: []) replies: Dict = field(default_factory=lambda: {}) inReplyTo: str = '' + summary: str = '' tag: List[Link] = field(default_factory=lambda: []) attachment: List[Image] = field(default_factory=lambda: []) sensitive: bool = False @@ -52,8 +53,8 @@ class Comment(Note): @dataclass(init=False) class Review(Comment): ''' a full book review ''' - name: str - rating: int + name: str = None + rating: int = None type: str = 'Review' diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py index 88349c02..7e7d027e 100644 --- a/bookwyrm/activitypub/person.py +++ b/bookwyrm/activitypub/person.py @@ -18,13 +18,13 @@ class PublicKey(ActivityObject): class Person(ActivityObject): ''' actor activitypub json ''' preferredUsername: str - name: str inbox: str outbox: str followers: str - summary: str publicKey: PublicKey endpoints: Dict + name: str = None + summary: str = None icon: Image = field(default_factory=lambda: {}) bookwyrmUser: bool = False manuallyApprovesFollowers: str = False diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py index e890d81f..7c627927 100644 --- a/bookwyrm/activitypub/verbs.py +++ b/bookwyrm/activitypub/verbs.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from typing import List from .base_activity import ActivityObject, Signature -from .book import Book +from .book import Edition @dataclass(init=False) class Verb(ActivityObject): @@ -73,7 +73,7 @@ class Add(Verb): @dataclass(init=False) class AddBook(Verb): '''Add activity that's aware of the book obj ''' - target: Book + target: Edition type: str = 'Add' diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index c9f1ad2e..86ac7435 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,16 +1,14 @@ ''' functionality outline for a book data connector ''' from abc import ABC, abstractmethod from dataclasses import dataclass -import pytz from urllib3.exceptions import RequestError from django.db import transaction -from dateutil import parser import requests from requests import HTTPError from requests.exceptions import SSLError -from bookwyrm import models +from bookwyrm import activitypub, models class ConnectorException(HTTPError): @@ -38,7 +36,7 @@ class AbstractMinimalConnector(ABC): for field in self_fields: setattr(self, field, getattr(info, field)) - def search(self, query, min_confidence=None): + def search(self, query, min_confidence=None):# pylint: disable=unused-argument ''' free text search ''' resp = requests.get( '%s%s' % (self.search_url, query), @@ -72,9 +70,6 @@ class AbstractConnector(AbstractMinimalConnector): ''' generic book data connector ''' def __init__(self, identifier): super().__init__(identifier) - - self.key_mappings = [] - # fields we want to look for in book data to copy over # title we handle separately. self.book_mappings = [] @@ -88,217 +83,110 @@ class AbstractConnector(AbstractMinimalConnector): return True + @transaction.atomic def get_or_create_book(self, remote_id): - # try to load the book - book = models.Book.objects.select_subclasses().filter( - origin_id=remote_id - ).first() - if book: - if isinstance(book, models.Work): - return book.default_edition - return book + ''' translate arbitrary json into an Activitypub dataclass ''' + # first, check if we have the origin_id saved + existing = models.Edition.find_existing_by_remote_id(remote_id) or \ + models.Work.find_existing_by_remote_id(remote_id) + if existing: + if hasattr(existing, 'get_default_editon'): + return existing.get_default_editon() + return existing - # no book was found, so we start creating a new one + # load the json data = get_data(remote_id) - - work = None - edition = None + mapped_data = dict_from_mappings(data, self.book_mappings) if self.is_work_data(data): - work_data = data - # if we requested a work and there's already an edition, we're set - work = self.match_from_mappings(work_data, models.Work) - if work and work.default_edition: - return work.default_edition - - # no such luck, we need more information. try: - edition_data = self.get_edition_from_work_data(work_data) + edition_data = self.get_edition_from_work_data(data) except KeyError: # hack: re-use the work data as the edition data # this is why remote ids aren't necessarily unique edition_data = data + work_data = mapped_data else: - edition_data = data - edition = self.match_from_mappings(edition_data, models.Edition) - # no need to figure out about the work if we already know about it - if edition and edition.parent_work: - return edition - - # no such luck, we need more information. try: - work_data = self.get_work_from_edition_date(edition_data) + work_data = self.get_work_from_edition_data(data) + work_data = dict_from_mappings(work_data, self.book_mappings) except KeyError: - # remember this hack: re-use the work data as the edition data - work_data = data + work_data = mapped_data + edition_data = data if not work_data or not edition_data: raise ConnectorException('Unable to load book data: %s' % remote_id) - # at this point, we need to figure out the work, edition, or both - # atomic so that we don't save a work with no edition for vice versa - with transaction.atomic(): - if not work: - work_key = self.get_remote_id_from_data(work_data) - work = self.create_book(work_key, work_data, models.Work) + # create activitypub object + work_activity = activitypub.Work(**work_data) + # this will dedupe automatically + work = work_activity.to_model(models.Work) + for author in self.get_authors_from_data(data): + work.authors.add(author) + return self.create_edition_from_data(work, edition_data) - if not edition: - ed_key = self.get_remote_id_from_data(edition_data) - edition = self.create_book(ed_key, edition_data, models.Edition) - edition.parent_work = work - edition.save() - work.default_edition = edition - work.save() - # now's our change to fill in author gaps + def create_edition_from_data(self, work, edition_data): + ''' if we already have the work, we're ready ''' + mapped_data = dict_from_mappings(edition_data, self.book_mappings) + mapped_data['work'] = work.remote_id + edition_activity = activitypub.Edition(**mapped_data) + edition = edition_activity.to_model(models.Edition) + edition.connector = self.connector + edition.save() + + work.default_edition = edition + work.save() + + for author in self.get_authors_from_data(edition_data): + edition.authors.add(author) if not edition.authors.exists() and work.authors.exists(): edition.authors.set(work.authors.all()) - edition.author_text = work.author_text - edition.save() - - if not edition: - raise ConnectorException('Unable to create book: %s' % remote_id) return edition - def create_book(self, remote_id, data, model): - ''' create a work or edition from data ''' - book = model.objects.create( - origin_id=remote_id, - title=data['title'], - connector=self.connector, - ) - return self.update_book_from_data(book, data) + def get_or_create_author(self, remote_id): + ''' load that author ''' + existing = models.Author.find_existing_by_remote_id(remote_id) + if existing: + return existing + data = get_data(remote_id) - def update_book_from_data(self, book, data, update_cover=True): - ''' for creating a new book or syncing with data ''' - book = update_from_mappings(book, data, self.book_mappings) - - author_text = [] - for author in self.get_authors_from_data(data): - book.authors.add(author) - author_text.append(author.name) - book.author_text = ', '.join(author_text) - book.save() - - if not update_cover: - return book - - cover = self.get_cover_from_data(data) - if cover: - book.cover.save(*cover, save=True) - return book - - - def update_book(self, book, data=None): - ''' load new data ''' - if not book.sync and not book.sync_cover: - return book - - if not data: - key = getattr(book, self.key_name) - data = self.load_book_data(key) - - if book.sync: - book = self.update_book_from_data( - book, data, update_cover=book.sync_cover) - else: - cover = self.get_cover_from_data(data) - if cover: - book.cover.save(*cover, save=True) - - return book - - - def match_from_mappings(self, data, model): - ''' try to find existing copies of this book using various keys ''' - relevent_mappings = [m for m in self.key_mappings if \ - not m.model or model == m.model] - for mapping in relevent_mappings: - # check if this field is present in the data - value = data.get(mapping.remote_field) - if not value: - continue - - # extract the value in the right format - value = mapping.formatter(value) - - # search our database for a matching book - kwargs = {mapping.local_field: value} - match = model.objects.filter(**kwargs).first() - if match: - return match - return None - - - @abstractmethod - def get_remote_id_from_data(self, data): - ''' otherwise we won't properly set the remote_id in the db ''' + mapped_data = dict_from_mappings(data, self.author_mappings) + activity = activitypub.Author(**mapped_data) + # this will dedupe + return activity.to_model(models.Author) @abstractmethod def is_work_data(self, data): ''' differentiate works and editions ''' - @abstractmethod def get_edition_from_work_data(self, data): ''' every work needs at least one edition ''' - @abstractmethod def get_work_from_edition_date(self, data): ''' every edition needs a work ''' - @abstractmethod def get_authors_from_data(self, data): ''' load author data ''' - - @abstractmethod - def get_cover_from_data(self, data): - ''' load cover ''' - @abstractmethod def expand_book_data(self, book): ''' get more info on a book ''' -def update_from_mappings(obj, data, mappings): - ''' assign data to model with mappings ''' +def dict_from_mappings(data, mappings): + ''' create a dict in Activitypub format, using mappings supplies by + the subclass ''' + result = {} for mapping in mappings: - # check if this field is present in the data - value = data.get(mapping.remote_field) - if not value: - continue - - # extract the value in the right format - try: - value = mapping.formatter(value) - except: - continue - - # assign the formatted value to the model - obj.__setattr__(mapping.local_field, value) - return obj - - -def get_date(date_string): - ''' helper function to try to interpret dates ''' - if not date_string: - return None - - try: - return pytz.utc.localize(parser.parse(date_string)) - except ValueError: - pass - - try: - return parser.parse(date_string) - except ValueError: - return None + result[mapping.local_field] = mapping.get_value(data) + return result def get_data(url): @@ -349,11 +237,19 @@ class SearchResult: class Mapping: ''' associate a local database field with a field in an external dataset ''' - def __init__( - self, local_field, remote_field=None, formatter=None, model=None): + def __init__(self, local_field, remote_field=None, formatter=None): noop = lambda x: x self.local_field = local_field self.remote_field = remote_field or local_field self.formatter = formatter or noop - self.model = model + + def get_value(self, data): + ''' pull a field from incoming json and return the formatted version ''' + value = data.get(self.remote_field) + if not value: + return None + try: + return self.formatter(value) + except:# pylint: disable=bare-except + return None diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 28eb1ea0..3b60c307 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -1,13 +1,9 @@ ''' openlibrary data connector ''' import re -import requests - -from django.core.files.base import ContentFile from bookwyrm import models from .abstract_connector import AbstractConnector, SearchResult, Mapping -from .abstract_connector import ConnectorException -from .abstract_connector import get_date, get_data, update_from_mappings +from .abstract_connector import ConnectorException, get_data from .openlibrary_languages import languages @@ -17,62 +13,57 @@ class Connector(AbstractConnector): super().__init__(identifier) get_first = lambda a: a[0] - self.key_mappings = [ - Mapping('isbn_13', model=models.Edition, formatter=get_first), - Mapping('isbn_10', model=models.Edition, formatter=get_first), - Mapping('lccn', model=models.Work, formatter=get_first), + get_remote_id = lambda a: self.base_url + a + self.book_mappings = [ + Mapping('title'), + Mapping('id', remote_field='key', formatter=get_remote_id), Mapping( - 'oclc_number', - remote_field='oclc_numbers', - model=models.Edition, - formatter=get_first - ), - Mapping( - 'openlibrary_key', - remote_field='key', - formatter=get_openlibrary_key - ), - Mapping('goodreads_key'), - Mapping('asin'), - ] - - self.book_mappings = self.key_mappings + [ - Mapping('sort_title'), + 'cover', remote_field='covers', formatter=self.get_cover_url), + Mapping('sortTitle', remote_field='sort_title'), Mapping('subtitle'), Mapping('description', formatter=get_description), Mapping('languages', formatter=get_languages), Mapping('series', formatter=get_first), - Mapping('series_number'), + Mapping('seriesNumber', remote_field='series_number'), Mapping('subjects'), - Mapping('subject_places'), + Mapping('subjectPlaces'), + Mapping('isbn13', formatter=get_first), + Mapping('isbn10', formatter=get_first), + Mapping('lccn', formatter=get_first), Mapping( - 'first_published_date', - remote_field='first_publish_date', - formatter=get_date + 'oclcNumber', remote_field='oclc_numbers', + formatter=get_first ), Mapping( - 'published_date', - remote_field='publish_date', - formatter=get_date + 'openlibraryKey', remote_field='key', + formatter=get_openlibrary_key ), + Mapping('goodreadsKey', remote_field='goodreads_key'), + Mapping('asin'), Mapping( - 'pages', - model=models.Edition, - remote_field='number_of_pages' + 'firstPublishedDate', remote_field='first_publish_date', ), - Mapping('physical_format', model=models.Edition), + Mapping('publishedDate', remote_field='publish_date'), + Mapping('pages', remote_field='number_of_pages'), + Mapping('physicalFormat', remote_field='physical_format'), Mapping('publishers'), ] self.author_mappings = [ + Mapping('id', remote_field='key', formatter=get_remote_id), Mapping('name'), - Mapping('born', remote_field='birth_date', formatter=get_date), - Mapping('died', remote_field='death_date', formatter=get_date), + Mapping( + 'openlibraryKey', remote_field='key', + formatter=get_openlibrary_key + ), + Mapping('born', remote_field='birth_date'), + Mapping('died', remote_field='death_date'), Mapping('bio', formatter=get_description), ] def get_remote_id_from_data(self, data): + ''' format a url from an openlibrary id field ''' try: key = data['key'] except KeyError: @@ -107,24 +98,17 @@ class Connector(AbstractConnector): ''' parse author json and load or create authors ''' for author_blob in data.get('authors', []): author_blob = author_blob.get('author', author_blob) - # this id is "/authors/OL1234567A" and we want just "OL1234567A" - author_id = author_blob['key'].split('/')[-1] - yield self.get_or_create_author(author_id) + # this id is "/authors/OL1234567A" + author_id = author_blob['key'] + url = '%s/%s.json' % (self.base_url, author_id) + yield self.get_or_create_author(url) - def get_cover_from_data(self, data): + def get_cover_url(self, cover_blob): ''' ask openlibrary for the cover ''' - if not data.get('covers'): - return None - - cover_id = data.get('covers')[0] + cover_id = cover_blob[0] image_name = '%s-M.jpg' % cover_id - url = '%s/b/id/%s' % (self.covers_url, image_name) - response = requests.get(url) - if not response.ok: - response.raise_for_status() - image_content = ContentFile(response.content) - return [image_name, image_content] + return '%s/b/id/%s' % (self.covers_url, image_name) def parse_search_data(self, data): @@ -158,37 +142,7 @@ class Connector(AbstractConnector): # we can mass download edition data from OL to avoid repeatedly querying edition_options = self.load_edition_data(work.openlibrary_key) for edition_data in edition_options.get('entries'): - olkey = edition_data.get('key').split('/')[-1] - # make sure the edition isn't already in the database - if models.Edition.objects.filter(openlibrary_key=olkey).count(): - continue - - # creates and populates the book from the data - edition = self.create_book(olkey, edition_data, models.Edition) - # ensures that the edition is associated with the work - edition.parent_work = work - edition.save() - # get author data from the work if it's missing from the edition - if not edition.authors and work.authors: - edition.authors.set(work.authors.all()) - - - def get_or_create_author(self, olkey): - ''' load that author ''' - if not re.match(r'^OL\d+A$', olkey): - raise ValueError('Invalid OpenLibrary author ID') - author = models.Author.objects.filter(openlibrary_key=olkey).first() - if author: - return author - - url = '%s/authors/%s.json' % (self.base_url, olkey) - data = get_data(url) - - author = models.Author(openlibrary_key=olkey) - author = update_from_mappings(author, data, self.author_mappings) - author.save() - - return author + self.create_edition_from_data(work, edition_data) def get_description(description_blob): @@ -220,7 +174,7 @@ def pick_default_edition(options): if len(options) == 1: 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 \ '/languages/eng' in str(e.get('languages'))] or options formats = ['paperback', 'hardcover', 'mass market paperback'] diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py index 80d3a67d..8d31c8a1 100644 --- a/bookwyrm/connectors/self_connector.py +++ b/bookwyrm/connectors/self_connector.py @@ -13,7 +13,7 @@ class Connector(AbstractConnector): that gets implemented it will totally rule ''' vector = SearchVector('title', weight='A') +\ SearchVector('subtitle', weight='B') +\ - SearchVector('author_text', weight='C') +\ + SearchVector('authors__name', weight='C') +\ SearchVector('isbn_13', weight='A') +\ SearchVector('isbn_10', weight='A') +\ SearchVector('openlibrary_key', weight='C') +\ diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index a2c3e24b..1422b4b9 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -60,25 +60,36 @@ class RatingForm(CustomForm): class ReviewForm(CustomForm): class Meta: model = models.Review - fields = ['user', 'book', 'name', 'content', 'rating', 'privacy'] + fields = [ + 'user', 'book', + 'name', 'content', 'rating', + 'content_warning', 'sensitive', + 'privacy'] class CommentForm(CustomForm): class Meta: model = models.Comment - fields = ['user', 'book', 'content', 'privacy'] + fields = [ + 'user', 'book', 'content', + 'content_warning', 'sensitive', + 'privacy'] class QuotationForm(CustomForm): class Meta: model = models.Quotation - fields = ['user', 'book', 'quote', 'content', 'privacy'] + fields = [ + 'user', 'book', 'quote', 'content', + 'content_warning', 'sensitive', 'privacy'] class ReplyForm(CustomForm): class Meta: model = models.Status - fields = ['user', 'content', 'reply_parent', 'privacy'] + fields = [ + 'user', 'content', 'content_warning', 'sensitive', + 'reply_parent', 'privacy'] class EditUserForm(CustomForm): diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index 9c8c2887..ddf99f97 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -57,7 +57,6 @@ def shared_inbox(request): 'Announce': handle_boost, 'Add': { 'Edition': handle_add, - 'Work': handle_add, }, 'Undo': { 'Follow': handle_unfollow, @@ -198,28 +197,15 @@ def handle_create(activity): # not a type of status we are prepared to deserialize return - if activity.type == 'Note': - # keep notes if they are replies to existing statuses - reply = models.Status.objects.filter( - remote_id=activity.inReplyTo - ).first() - - if not reply: - discard = True - # keep notes if they mention local users - tags = [l['href'] for l in activity.tag if l['type'] == 'Mention'] - for tag in tags: - if models.User.objects.filter( - remote_id=tag, local=True).exists(): - # we found a mention of a known use boost - discard = False - break - if discard: - return - status = activity.to_model(model) + if not status: + # it was discarded because it's not a bookwyrm type + return + # create a notification if this is a reply + notified = [] if status.reply_parent and status.reply_parent.user.local: + notified.append(status.reply_parent.user) status_builder.create_notification( status.reply_parent.user, 'REPLY', @@ -228,7 +214,7 @@ def handle_create(activity): ) if status.mention_users.exists(): for mentioned_user in status.mention_users.all(): - if not mentioned_user.local: + if not mentioned_user.local or mentioned_user in notified: continue status_builder.create_notification( mentioned_user, diff --git a/bookwyrm/migrations/0017_auto_20201212_0059.py b/bookwyrm/migrations/0017_auto_20201212_0059.py new file mode 100644 index 00000000..c9e3fcf4 --- /dev/null +++ b/bookwyrm/migrations/0017_auto_20201212_0059.py @@ -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'), + ), + ] diff --git a/bookwyrm/migrations/0023_merge_20201216_0112.py b/bookwyrm/migrations/0023_merge_20201216_0112.py new file mode 100644 index 00000000..e3af4849 --- /dev/null +++ b/bookwyrm/migrations/0023_merge_20201216_0112.py @@ -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 = [ + ] diff --git a/bookwyrm/migrations/0024_merge_20201216_1721.py b/bookwyrm/migrations/0024_merge_20201216_1721.py new file mode 100644 index 00000000..41f81335 --- /dev/null +++ b/bookwyrm/migrations/0024_merge_20201216_1721.py @@ -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 = [ + ] diff --git a/bookwyrm/migrations/0025_auto_20201217_0046.py b/bookwyrm/migrations/0025_auto_20201217_0046.py new file mode 100644 index 00000000..a3ffe8c1 --- /dev/null +++ b/bookwyrm/migrations/0025_auto_20201217_0046.py @@ -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=''), + ), + ] diff --git a/bookwyrm/migrations/0026_status_content_warning.py b/bookwyrm/migrations/0026_status_content_warning.py new file mode 100644 index 00000000..f4e494db --- /dev/null +++ b/bookwyrm/migrations/0026_status_content_warning.py @@ -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), + ), + ] diff --git a/bookwyrm/migrations/0027_auto_20201220_2007.py b/bookwyrm/migrations/0027_auto_20201220_2007.py new file mode 100644 index 00000000..a3ad4dda --- /dev/null +++ b/bookwyrm/migrations/0027_auto_20201220_2007.py @@ -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), + ), + ] diff --git a/bookwyrm/migrations/0028_remove_book_author_text.py b/bookwyrm/migrations/0028_remove_book_author_text.py new file mode 100644 index 00000000..8743c910 --- /dev/null +++ b/bookwyrm/migrations/0028_remove_book_author_text.py @@ -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', + ), + ] diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py index 331d2dd6..a2eac507 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -21,18 +21,17 @@ class Author(ActivitypubMixin, BookWyrmModel): # idk probably other keys would be useful here? born = 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( 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): - ''' can't be abstract for query reasons, but you shouldn't USE it ''' - if self.id and not self.remote_id: + ''' handle remote vs origin ids ''' + if self.id: self.remote_id = self.get_remote_id() - - if not self.id: + else: self.origin_id = self.remote_id self.remote_id = None return super().save(*args, **kwargs) diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 08cc6052..0de61fd1 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -151,9 +151,9 @@ class ActivitypubMixin: 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 ''' - activity_object = self.to_activity() + activity_object = self.to_activity(**kwargs) signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key)) content = activity_object['content'] diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index bcd4bc04..21311d6c 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -36,7 +36,7 @@ class Book(ActivitypubMixin, BookWyrmModel): title = fields.CharField(max_length=255) sort_title = 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( 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) authors = fields.ManyToManyField('Author') # preformatted authorship string for search and easier display - author_text = models.CharField(max_length=255, blank=True, null=True) - cover = fields.ImageField(upload_to='covers/', blank=True, null=True) + cover = fields.ImageField( + upload_to='covers/', blank=True, null=True, alt_field='alt_text') first_published_date = fields.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True) 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): ''' can't be abstract for query reasons, but you shouldn't USE it ''' if not isinstance(self, Edition) and not isinstance(self, Work): 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() - - if not self.id: + else: self.origin_id = self.remote_id self.remote_id = None return super().save(*args, **kwargs) @@ -92,7 +115,8 @@ class Work(OrderedCollectionPageMixin, Book): default_edition = fields.ForeignKey( 'Edition', on_delete=models.PROTECT, - null=True + null=True, + load_remote=False ) def get_default_edition(self): diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index f6142e37..6dd3b496 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -5,7 +5,6 @@ from uuid import uuid4 import dateutil.parser from dateutil.parser import ParserError -from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import ArrayField as DjangoArrayField from django.core.exceptions import ValidationError from django.core.files.base import ContentFile @@ -13,8 +12,9 @@ from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ from bookwyrm import activitypub -from bookwyrm.settings import DOMAIN from bookwyrm.connectors import get_image +from bookwyrm.sanitize_html import InputHtmlParser +from bookwyrm.settings import DOMAIN 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: ''' make a database field serializable ''' def __init__(self, *args, \ @@ -63,6 +72,9 @@ class ActivitypubFieldMixin: return key = self.get_activitypub_field() + # TODO: surely there's a better way + if instance.__class__.__name__ == 'Boost' and key == 'attributedTo': + key = 'actor' if isinstance(activity.get(key), list): activity[key] += formatted else: @@ -92,12 +104,19 @@ class ActivitypubFieldMixin: class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): ''' 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): if not value: return None related_model = self.related_model 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 activity_serializer = related_model.activity_serializer return activity_serializer(**value).to_model(related_model) @@ -108,6 +127,9 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin): # we don't know what this is, ignore it return None # 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) @@ -134,7 +156,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField): _('username'), max_length=150, unique=True, - validators=[AbstractUser.username_validator], + validators=[validate_username], error_messages={ 'unique': _('A user with that username already exists.'), }, @@ -276,6 +298,8 @@ class TagField(ManyToManyField): for link_json in value: link = activitypub.Link(**link_json) 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: # tags can contain multiple types continue @@ -285,18 +309,22 @@ class TagField(ManyToManyField): return items -def image_serializer(value): +def image_serializer(value, alt): ''' helper for serializing images ''' if value and hasattr(value, 'url'): url = value.url else: return None url = 'https://%s%s' % (DOMAIN, url) - return activitypub.Image(url=url) + return activitypub.Image(url=url, name=alt) class ImageField(ActivitypubFieldMixin, models.ImageField): ''' 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 def set_field_from_activity(self, instance, data, save=True): ''' helper function for assinging a value to the field ''' @@ -306,9 +334,19 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): return 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): - return image_serializer(value) + key = self.get_activitypub_field() + activity[key] = formatted + + + def field_to_activity(self, value, alt=None): + return image_serializer(value, alt) def field_from_activity(self, value): @@ -353,6 +391,15 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField): except (ParserError, TypeError): 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): ''' activitypub-aware array field ''' def field_to_activity(self, value): diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 68f3614f..69df43b4 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -3,7 +3,7 @@ import re from django.db import models from bookwyrm import activitypub -from .base_model import BookWyrmModel +from .base_model import ActivitypubMixin, BookWyrmModel from .base_model import OrderedCollectionMixin from . import fields @@ -51,7 +51,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): unique_together = ('user', 'identifier') -class ShelfBook(BookWyrmModel): +class ShelfBook(ActivitypubMixin, BookWyrmModel): ''' many to many join table for books and shelves ''' book = fields.ForeignKey( 'Edition', on_delete=models.PROTECT, activitypub_field='object') diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index f0cd3c1d..3654e554 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -1,7 +1,11 @@ ''' 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.db import models +from django.utils import timezone from model_utils.managers import InheritanceManager from bookwyrm import activitypub @@ -14,10 +18,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ''' any post, like a reply to a review, etc ''' user = fields.ForeignKey( '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_books = fields.TagField('Edition', related_name='mention_book') 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) sensitive = fields.BooleanField(default=False) # created date is different than publish date because of federated posts @@ -44,6 +50,27 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): serialize_reverse_fields = [('attachments', 'attachment')] deserialize_reverse_fields = [('attachments', 'attachment')] + @classmethod + def ignore_activity(cls, activity): + ''' keep notes if they are replies to existing statuses ''' + if activity.type != 'Note': + return False + if cls.objects.filter( + remote_id=activity.inReplyTo).exists(): + return False + + # keep notes if they mention local users + if activity.tag == MISSING or activity.tag is None: + return True + tags = [l['href'] for l in activity.tag if l['type'] == 'Mention'] + for tag in tags: + user_model = apps.get_model('bookwyrm.User', require_ready=True) + if user_model.objects.filter( + remote_id=tag, local=True).exists(): + # we found a mention of a known use boost + return False + return True + @classmethod def replies(cls, status): ''' load all replies to a status. idk if there's a better way @@ -57,6 +84,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ''' expose the type of status for the ui using activity type ''' return self.activity_serializer.__name__ + @property + def boostable(self): + ''' you can't boost dms ''' + return self.privacy in ['unlisted', 'public'] + def to_replies(self, **kwargs): ''' helper function for loading AP serialized replies to a status ''' return self.to_ordered_collection( @@ -78,17 +110,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): activity['replies'] = self.to_replies() # "pure" serialization for non-bookwyrm instances - if pure: + if pure and hasattr(self, 'pure_content'): activity['content'] = self.pure_content if 'name' in activity: activity['name'] = self.pure_name activity['type'] = self.pure_type activity['attachment'] = [ - image_serializer(b.cover) for b in self.mention_books.all() \ - if b.cover] + image_serializer(b.cover, b.alt_text) \ + for b in self.mention_books.all()[:4] if b.cover] if hasattr(self, 'book'): activity['attachment'].append( - image_serializer(self.book.cover) + image_serializer(self.book.cover, self.book.alt_text) ) return activity @@ -125,8 +157,8 @@ class Comment(Status): @property def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' - return self.content + '

(comment on "%s")' % \ - (self.book.remote_id, self.book.title) + return '%s

(comment on "%s")

' % \ + (self.content, self.book.remote_id, self.book.title) activity_serializer = activitypub.Comment pure_type = 'Note' @@ -134,15 +166,17 @@ class Comment(Status): class Quotation(Status): ''' like a review but without a rating and transient ''' - quote = fields.TextField() + quote = fields.HtmlField() book = fields.ForeignKey( 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook') @property def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' - return '"%s"
-- "%s"

%s' % ( - self.quote, + quote = re.sub(r'^

', '

"', self.quote) + quote = re.sub(r'

$', '"

', quote) + return '%s

-- "%s"

%s' % ( + quote, self.book.remote_id, self.book.title, self.content, @@ -182,8 +216,7 @@ class Review(Status): @property def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' - return self.content + '

("%s")' % \ - (self.book.remote_id, self.book.title) + return self.content activity_serializer = activitypub.Review pure_type = 'Article' @@ -240,7 +273,7 @@ class Boost(Status): class ReadThrough(BookWyrmModel): ''' Store progress through a book in the database. ''' 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( null=True, blank=True) diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 63549d36..30eeffbc 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -42,7 +42,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): blank=True, ) outbox = fields.RemoteIdField(unique=True) - summary = fields.TextField(default='') + summary = fields.HtmlField(null=True, blank=True) local = models.BooleanField(default=False) bookwyrm_user = fields.BooleanField(default=True) localname = models.CharField( @@ -51,9 +51,10 @@ class User(OrderedCollectionPageMixin, AbstractUser): unique=True ) # 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( - 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( 'self', link_only=True, @@ -90,10 +91,16 @@ class User(OrderedCollectionPageMixin, AbstractUser): last_active_date = models.DateTimeField(auto_now=True) manually_approves_followers = fields.BooleanField(default=False) + name_field = 'username' + @property + def alt_text(self): + ''' alt text with username ''' + return 'avatar for %s' % (self.localname or self.username) + @property def display_name(self): ''' show the cleanest version of the user's name possible ''' - if self.name != '': + if self.name and self.name != '': return self.name return self.localname or self.username diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py index 38b48282..13df9026 100644 --- a/bookwyrm/outgoing.py +++ b/bookwyrm/outgoing.py @@ -4,12 +4,14 @@ import re from django.db import IntegrityError, transaction from django.http import HttpResponseNotFound, JsonResponse from django.views.decorators.csrf import csrf_exempt +from markdown import markdown from requests import HTTPError from bookwyrm import activitypub from bookwyrm import models from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.broadcast import broadcast +from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.status import create_notification from bookwyrm.status import create_generated_note from bookwyrm.status import delete_status @@ -209,15 +211,15 @@ def handle_delete_status(user, status): def handle_status(user, form): ''' 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 - text = status.content - matches = re.finditer( - regex.username, - text - ) - for match in matches: + matches = [] + for match in re.finditer(regex.username, status.content): username = match.group().strip().split('@')[1:] if len(username) == 1: # this looks like a local user (@user), fill in the domain @@ -228,6 +230,7 @@ def handle_status(user, form): if not mention_user: # we can ignore users we don't know about continue + matches.append((match.group(), mention_user.remote_id)) # add them to status mentions fk status.mention_users.add(mention_user) # create notification if the mentioned user is local @@ -238,6 +241,17 @@ def handle_status(user, form): related_user=user, related_status=status ) + # add mentions + content = status.content + for (username, url) in matches: + content = re.sub( + r'%s([^@])' % username, + r'%s\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() # notify reply parent or tagged users @@ -252,9 +266,22 @@ def handle_status(user, form): broadcast(user, status.to_create_activity(user), software='bookwyrm') # re-format the activity for non-bookwyrm servers - if hasattr(status, 'pure_activity_serializer'): - remote_activity = status.to_create_activity(user, pure=True) - broadcast(user, remote_activity, software='other') + remote_activity = status.to_create_activity(user, pure=True) + 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>\g<3>', + content) + content = markdown(content) + # sanitize resulting html + sanitizer = InputHtmlParser() + sanitizer.feed(content) + return sanitizer.get_output() def handle_tag(user, tag): @@ -312,15 +339,19 @@ def handle_unfavorite(user, status): def handle_boost(user, status): ''' a user wishes to boost a status ''' + # is it boostable? + if not status.boostable: + return + if models.Boost.objects.filter( boosted_status=status, user=user).exists(): # you already boosted that. return boost = models.Boost.objects.create( boosted_status=status, + privacy=status.privacy, user=user, ) - boost.save() boost_activity = boost.to_activity() broadcast(user, boost_activity) diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py index 9c5ca73a..de13ede8 100644 --- a/bookwyrm/sanitize_html.py +++ b/bookwyrm/sanitize_html.py @@ -1,12 +1,16 @@ ''' html parser to clean up incoming text from unknown sources ''' 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 ''' def __init__(self): HTMLParser.__init__(self) - self.allowed_tags = ['p', 'b', 'i', 'pre', 'a', 'span'] + self.allowed_tags = [ + 'p', 'br', + 'b', 'i', 'strong', 'em', 'pre', + 'a', 'span', 'ul', 'ol', 'li' + ] self.tag_stack = [] self.output = [] # if the html appears invalid, we just won't allow any at all diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index 9e8a24ba..5b5ff08d 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -65,6 +65,7 @@ input.toggle-control:checked ~ .modal.toggle-content { .cover-container { height: 250px; width: max-content; + max-width: 250px; } .cover-container.is-medium { height: 150px; @@ -136,8 +137,3 @@ input.toggle-control:checked ~ .modal.toggle-content { content: "\e904"; right: 0; } - -/* --- BLOCKQUOTE --- */ -blockquote { - white-space: pre-line; -} diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index 10c2a27b..506ee3db 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -4,13 +4,21 @@ {% block content %}
-
-

- {% include 'snippets/book_titleby.html' with book=book %} -

+
+
+

+ {{ book.title }}{% if book.subtitle %}: + {{ book.subtitle }}{% endif %} +

+ {% if book.authors %} +

+ by {% include 'snippets/authors.html' with book=book %} +

+ {% endif %} +
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %} -
+ - {% for readthrough in readthroughs %} -
- - -
- -
- - -
- + {# user's relationship to the book #}
- - + {% for shelf in user_shelves %} +

+ This edition is on your {{ shelf.shelf.name }} shelf. + {% include 'snippets/shelf_selector.html' with current=shelf.shelf %} +

+ {% endfor %} + + {% for shelf in other_edition_shelves %} +

+ A different edition of this book is on your {{ shelf.shelf.name }} shelf. + {% include 'snippets/switch_edition_button.html' with edition=book %} +

+ {% endfor %} + + {% for readthrough in readthroughs %} + {% include 'snippets/readthrough.html' with readthrough=readthrough %} + {% endfor %}
- {% endfor %} {% if request.user.is_authenticated %}
diff --git a/bookwyrm/templates/feed.html b/bookwyrm/templates/feed.html index 07ad8d0f..aec76acc 100644 --- a/bookwyrm/templates/feed.html +++ b/bookwyrm/templates/feed.html @@ -44,11 +44,13 @@
{% endfor %} diff --git a/bookwyrm/templates/notifications.html b/bookwyrm/templates/notifications.html index f31df76d..ddcbc0fd 100644 --- a/bookwyrm/templates/notifications.html +++ b/bookwyrm/templates/notifications.html @@ -54,7 +54,7 @@
{{ notification.related_status.published_date | post_date }} diff --git a/bookwyrm/templates/snippets/authors.html b/bookwyrm/templates/snippets/authors.html index e8106f5d..dd94b471 100644 --- a/bookwyrm/templates/snippets/authors.html +++ b/bookwyrm/templates/snippets/authors.html @@ -1 +1 @@ -{{ book.authors.first.name }} +{% for author in book.authors.all %}{{ author.name }}{% if not forloop.last %}, {% endif %}{% endfor %} diff --git a/bookwyrm/templates/snippets/avatar.html b/bookwyrm/templates/snippets/avatar.html index da4b5fd2..ca49075c 100644 --- a/bookwyrm/templates/snippets/avatar.html +++ b/bookwyrm/templates/snippets/avatar.html @@ -1,3 +1,3 @@ {% load bookwyrm_tags %} -avatar for {{ user|username }} +{{ user.alt_text }} diff --git a/bookwyrm/templates/snippets/book_cover.html b/bookwyrm/templates/snippets/book_cover.html index ceeef426..6d15b37f 100644 --- a/bookwyrm/templates/snippets/book_cover.html +++ b/bookwyrm/templates/snippets/book_cover.html @@ -1,13 +1,13 @@ {% load bookwyrm_tags %}
{% if book.cover %} - {% include 'snippets/cover_alt.html' with book=book %} +{{ book.alt_text }} {% else %}
No cover

{{ book.title }}

-

({{ book|edition_info }})

+

({{ book.edition_info }})

{% endif %} diff --git a/bookwyrm/templates/snippets/book_tiles.html b/bookwyrm/templates/snippets/book_tiles.html index 4fc7df31..85f685a8 100644 --- a/bookwyrm/templates/snippets/book_tiles.html +++ b/bookwyrm/templates/snippets/book_tiles.html @@ -1,16 +1,11 @@ -
+
{% for book in books %} - {% if forloop.counter0|divisibleby:"4" %} -
-
- {% endif %}
{% include 'snippets/book_cover.html' with book=book %} - {% include 'snippets/rate_action.html' with user=request.user book=book %} - {% include 'snippets/shelve_button.html' with book=book %} + {% include 'snippets/shelve_button.html' with book=book switch_mode=True %}
{% endfor %} diff --git a/bookwyrm/templates/snippets/boost_button.html b/bookwyrm/templates/snippets/boost_button.html index bf06cef7..02b55794 100644 --- a/bookwyrm/templates/snippets/boost_button.html +++ b/bookwyrm/templates/snippets/boost_button.html @@ -1,8 +1,9 @@ {% load bookwyrm_tags %} + {% with status.id|uuid as uuid %}
{% csrf_token %} -
{% endif %} + + {% include 'snippets/content_warning_field.html' %} + {% if type == 'quote' %} {% else %} diff --git a/bookwyrm/templates/snippets/rate_action.html b/bookwyrm/templates/snippets/rate_action.html index b9c443ce..49cb87ed 100644 --- a/bookwyrm/templates/snippets/rate_action.html +++ b/bookwyrm/templates/snippets/rate_action.html @@ -4,7 +4,9 @@ {% for i in '12345'|make_list %} {% csrf_token %} + + + +
+ +
+
+
+ +
+ + +
diff --git a/bookwyrm/templates/snippets/reply_form.html b/bookwyrm/templates/snippets/reply_form.html index d0a0f6b9..787a3ac5 100644 --- a/bookwyrm/templates/snippets/reply_form.html +++ b/bookwyrm/templates/snippets/reply_form.html @@ -6,11 +6,12 @@
+ + {% include 'snippets/content_warning_field.html' with parent_status=activity %}
-
{% include 'snippets/privacy_select.html' with current=activity.privacy %} diff --git a/bookwyrm/templates/snippets/shelve_button.html b/bookwyrm/templates/snippets/shelve_button.html index d452169e..b5470044 100644 --- a/bookwyrm/templates/snippets/shelve_button.html +++ b/bookwyrm/templates/snippets/shelve_button.html @@ -4,24 +4,28 @@ {% with book.id|uuid as uuid %} {% active_shelf book as active_shelf %}
- {% 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' %} - {% elif active_shelf.identifier == 'reading' %} + {% elif active_shelf.shelf.identifier == 'reading' %} - {% include 'snippets/finish_reading_modal.html' %} - {% elif active_shelf.identifier == 'to-read' %} + {% include 'snippets/finish_reading_modal.html' with book=active_shelf.book %} + {% elif active_shelf.shelf.identifier == 'to-read' %} - {% include 'snippets/start_reading_modal.html' %} + {% include 'snippets/start_reading_modal.html' with book=active_shelf.book %} {% else %}
{% csrf_token %} - +
@@ -40,17 +44,17 @@
+ {% endif %}
{% endwith %} {% endif %} diff --git a/bookwyrm/templates/snippets/status_content.html b/bookwyrm/templates/snippets/status_content.html index 2ef06421..95d6f8ee 100644 --- a/bookwyrm/templates/snippets/status_content.html +++ b/bookwyrm/templates/snippets/status_content.html @@ -1,38 +1,57 @@ {% load bookwyrm_tags %}
{% if status.status_type == 'Review' %} -

- {% if status.name %}{{ status.name }}
{% endif %} - {% include 'snippets/stars.html' with rating=status.rating %} -

- {% endif %} - - {% if status.quote %} -
-
{{ status.quote }}
- -

— {% include 'snippets/book_titleby.html' with book=status.book %}

+
+

+ {% if status.name %}{{ status.name }}
{% endif %} +

+

{% include 'snippets/stars.html' with rating=status.rating %}

{% 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 %} -
-
- {% for attachment in status.attachments.all %} -
-
- - {{ attachment.caption }} - -
-
- {% endfor %} + {% if status.content_warning %} +
+

{{ status.content_warning }}

+ +
+ + {% endif %} +
{% if not hide_book %} diff --git a/bookwyrm/templates/snippets/switch_edition_button.html b/bookwyrm/templates/snippets/switch_edition_button.html new file mode 100644 index 00000000..685aed7c --- /dev/null +++ b/bookwyrm/templates/snippets/switch_edition_button.html @@ -0,0 +1,5 @@ + + {% csrf_token %} + + + diff --git a/bookwyrm/templates/snippets/trimmed_text.html b/bookwyrm/templates/snippets/trimmed_text.html index b3207a92..e8a1fa75 100644 --- a/bookwyrm/templates/snippets/trimmed_text.html +++ b/bookwyrm/templates/snippets/trimmed_text.html @@ -6,18 +6,18 @@ {% if trimmed != full %}
-
-
{% else %} -
{{ full }}
+
{{ full | to_markdown | safe }}
{% endif %} {% endwith %} diff --git a/bookwyrm/templates/snippets/user_header.html b/bookwyrm/templates/snippets/user_header.html index f4f9733f..c06465e8 100644 --- a/bookwyrm/templates/snippets/user_header.html +++ b/bookwyrm/templates/snippets/user_header.html @@ -23,7 +23,14 @@
{% if user.summary %} -
{{ user.summary | safe }}
+
+
+ +
+
+
{{ user.summary | to_markdown | safe }}
+
+
{% endif %}
diff --git a/bookwyrm/templatetags/bookwyrm_tags.py b/bookwyrm/templatetags/bookwyrm_tags.py index 849974bf..61b097bd 100644 --- a/bookwyrm/templatetags/bookwyrm_tags.py +++ b/bookwyrm/templatetags/bookwyrm_tags.py @@ -7,6 +7,7 @@ from django import template from django.utils import timezone from bookwyrm import models +from bookwyrm.outgoing import to_markdown register = template.Library() @@ -97,20 +98,6 @@ def get_boosted(boost): ).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') def get_book_description(book): ''' use the work's text if the book doesn't have it ''' @@ -132,9 +119,10 @@ def time_since(date): delta = now - date if date < (now - relativedelta(weeks=1)): + formatter = '%b %-d' if date.year != now.year: - return date.strftime('%b %-d %Y') - return date.strftime('%b %-d') + formatter += ' %Y' + return date.strftime(formatter) delta = relativedelta(now, date) if delta.days: return '%dd' % delta.days @@ -145,14 +133,21 @@ def time_since(date): 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) def active_shelf(context, book): ''' check what shelf a user has a book on, if any ''' shelf = models.ShelfBook.objects.filter( shelf__user=context['request'].user, - book=book + book__in=book.parent_work.editions.all() ).first() - return shelf.shelf if shelf else None + return shelf if shelf else {'book': book} @register.simple_tag(takes_context=False) diff --git a/bookwyrm/tests/activitypub/test_base_activity.py b/bookwyrm/tests/activitypub/test_base_activity.py index 87420aa7..4cdc2c70 100644 --- a/bookwyrm/tests/activitypub/test_base_activity.py +++ b/bookwyrm/tests/activitypub/test_base_activity.py @@ -103,7 +103,7 @@ class BaseActivity(TestCase): def test_to_model_simple_fields(self): ''' test setting simple fields ''' - self.assertEqual(self.user.name, '') + self.assertIsNone(self.user.name) activity = activitypub.Person( id=self.user.remote_id, diff --git a/bookwyrm/tests/connectors/test_abstract_connector.py b/bookwyrm/tests/connectors/test_abstract_connector.py index f05645ab..d6e02960 100644 --- a/bookwyrm/tests/connectors/test_abstract_connector.py +++ b/bookwyrm/tests/connectors/test_abstract_connector.py @@ -30,10 +30,10 @@ class AbstractConnector(TestCase): 'series': ['one', 'two'], } self.connector.key_mappings = [ - Mapping('isbn_10', model=models.Edition), + Mapping('isbn_10'), Mapping('isbn_13'), - Mapping('lccn', model=models.Work), - Mapping('asin', remote_field='ASIN'), + Mapping('lccn'), + Mapping('asin'), ] @@ -41,7 +41,6 @@ class AbstractConnector(TestCase): mapping = Mapping('isbn') self.assertEqual(mapping.local_field, 'isbn') self.assertEqual(mapping.remote_field, 'isbn') - self.assertEqual(mapping.model, None) self.assertEqual(mapping.formatter('bb'), 'bb') @@ -49,7 +48,6 @@ class AbstractConnector(TestCase): mapping = Mapping('isbn', remote_field='isbn13') self.assertEqual(mapping.local_field, 'isbn') self.assertEqual(mapping.remote_field, 'isbn13') - self.assertEqual(mapping.model, None) self.assertEqual(mapping.formatter('bb'), 'bb') @@ -59,40 +57,4 @@ class AbstractConnector(TestCase): self.assertEqual(mapping.local_field, 'isbn') self.assertEqual(mapping.remote_field, 'isbn') self.assertEqual(mapping.formatter, formatter) - self.assertEqual(mapping.model, None) self.assertEqual(mapping.formatter('bb'), 'aabb') - - - def test_match_from_mappings(self): - edition = models.Edition.objects.create( - title='Blah', - isbn_13='blahhh', - ) - match = self.connector.match_from_mappings(self.data, models.Edition) - self.assertEqual(match, edition) - - - def test_match_from_mappings_with_model(self): - edition = models.Edition.objects.create( - title='Blah', - isbn_10='1234567890', - ) - match = self.connector.match_from_mappings(self.data, models.Edition) - self.assertEqual(match, edition) - - - def test_match_from_mappings_with_remote(self): - edition = models.Edition.objects.create( - title='Blah', - asin='A00BLAH', - ) - match = self.connector.match_from_mappings(self.data, models.Edition) - self.assertEqual(match, edition) - - - def test_match_from_mappings_no_match(self): - edition = models.Edition.objects.create( - title='Blah', - ) - match = self.connector.match_from_mappings(self.data, models.Edition) - self.assertEqual(match, None) diff --git a/bookwyrm/tests/connectors/test_openlibrary_connector.py b/bookwyrm/tests/connectors/test_openlibrary_connector.py index 4a2bc6ea..e2d54cd3 100644 --- a/bookwyrm/tests/connectors/test_openlibrary_connector.py +++ b/bookwyrm/tests/connectors/test_openlibrary_connector.py @@ -1,15 +1,16 @@ ''' testing book data connectors ''' -from dateutil import parser -from django.test import TestCase import json import pathlib +from dateutil import parser +from django.test import TestCase import pytz from bookwyrm import models from bookwyrm.connectors.openlibrary import Connector from bookwyrm.connectors.openlibrary import get_languages, get_description -from bookwyrm.connectors.openlibrary import pick_default_edition, get_openlibrary_key -from bookwyrm.connectors.abstract_connector import SearchResult, get_date +from bookwyrm.connectors.openlibrary import pick_default_edition, \ + get_openlibrary_key +from bookwyrm.connectors.abstract_connector import SearchResult class Openlibrary(TestCase): @@ -43,7 +44,7 @@ class Openlibrary(TestCase): def test_pick_default_edition(self): 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): @@ -67,12 +68,6 @@ class Openlibrary(TestCase): self.assertEqual(description, expected) - def test_get_date(self): - date = get_date(self.work_data['first_publish_date']) - expected = pytz.utc.localize(parser.parse('1995')) - self.assertEqual(date, expected) - - def test_get_languages(self): languages = get_languages(self.edition_data['languages']) self.assertEqual(languages, ['English']) @@ -81,4 +76,3 @@ class Openlibrary(TestCase): def test_get_ol_key(self): key = get_openlibrary_key('/books/OL27320736M') self.assertEqual(key, 'OL27320736M') - diff --git a/bookwyrm/tests/connectors/test_self_connector.py b/bookwyrm/tests/connectors/test_self_connector.py index 4627bc8c..91857def 100644 --- a/bookwyrm/tests/connectors/test_self_connector.py +++ b/bookwyrm/tests/connectors/test_self_connector.py @@ -25,12 +25,13 @@ class SelfConnector(TestCase): self.work = models.Work.objects.create( title='Example Work', ) + author = models.Author.objects.create(name='Anonymous') self.edition = models.Edition.objects.create( title='Edition of Example Work', - author_text='Anonymous', published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc), parent_work=self.work, ) + self.edition.authors.add(author) models.Edition.objects.create( title='Another Edition', parent_work=self.work, @@ -41,11 +42,12 @@ class SelfConnector(TestCase): subtitle='The Anonymous Edition', parent_work=self.work, ) - models.Edition.objects.create( + + edition = models.Edition.objects.create( title='An Edition', - author_text='Fish', parent_work=self.work ) + edition.authors.add(models.Author.objects.create(name='Fish')) def test_format_search_result(self): diff --git a/bookwyrm/tests/models/test_book_model.py b/bookwyrm/tests/models/test_book_model.py index ff56b5d8..a52133ea 100644 --- a/bookwyrm/tests/models/test_book_model.py +++ b/bookwyrm/tests/models/test_book_model.py @@ -1,5 +1,7 @@ ''' testing models ''' +from dateutil.parser import parse from django.test import TestCase +from django.utils import timezone from bookwyrm import models, settings 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_10 = isbn_13_to_10(isbn_13) 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)') diff --git a/bookwyrm/tests/models/test_fields.py b/bookwyrm/tests/models/test_fields.py index 8c86b23c..10c674d9 100644 --- a/bookwyrm/tests/models/test_fields.py +++ b/bookwyrm/tests/models/test_fields.py @@ -21,31 +21,23 @@ from bookwyrm.activitypub.base_activity import ActivityObject from bookwyrm.models import fields, User, Status from bookwyrm.models.base_model import ActivitypubMixin, BookWyrmModel +#pylint: disable=too-many-public-methods class ActivitypubFields(TestCase): ''' overwrites standard model feilds to work with activitypub ''' def test_validate_remote_id(self): ''' should look like a url ''' - self.assertIsNone(fields.validate_remote_id( - 'http://www.example.com' - )) - self.assertIsNone(fields.validate_remote_id( - 'https://www.example.com' - )) - self.assertIsNone(fields.validate_remote_id( - 'http://example.com/dlfjg-23/x' - )) + self.assertIsNone(fields.validate_remote_id('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.assertRaises( ValidationError, fields.validate_remote_id, - 'http:/example.com/dlfjg-23/x' - ) + 'http:/example.com/dlfjg-23/x') self.assertRaises( ValidationError, fields.validate_remote_id, - 'www.example.com/dlfjg-23/x' - ) + 'www.example.com/dlfjg-23/x') self.assertRaises( 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): ''' generic mixin with super basic to and from functionality ''' @@ -71,6 +63,38 @@ class ActivitypubFields(TestCase): instance.name = 'snake_case_name' 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): ''' just sets some defaults on charfield ''' instance = fields.RemoteIdField() @@ -369,17 +393,19 @@ class ActivitypubFields(TestCase): ContentFile(output.getvalue()) ) - output = fields.image_serializer(user.avatar) + output = fields.image_serializer(user.avatar, alt='alt text') self.assertIsNotNone( re.match( r'.*\.jpg', output.url, ) ) + self.assertEqual(output.name, 'alt text') self.assertEqual(output.type, 'Image') instance = fields.ImageField() + output = fields.image_serializer(user.avatar, alt=None) self.assertEqual(instance.field_to_activity(user.avatar), output) responses.add( @@ -408,3 +434,12 @@ class ActivitypubFields(TestCase): ''' idk why it makes them strings but probably for a good reason ''' instance = fields.ArrayField(fields.IntegerField) 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('

hi

'), + '

hi

' + ) diff --git a/bookwyrm/tests/models/test_status_model.py b/bookwyrm/tests/models/test_status_model.py index 7c625197..d98b8b18 100644 --- a/bookwyrm/tests/models/test_status_model.py +++ b/bookwyrm/tests/models/test_status_model.py @@ -1,42 +1,274 @@ ''' testing models ''' +from io import BytesIO +import pathlib + +from PIL import Image +from django.core.files.base import ContentFile +from django.db import IntegrityError from django.test import TestCase +from django.utils import timezone from bookwyrm import models, settings class Status(TestCase): + ''' lotta types of statuses ''' def setUp(self): - user = models.User.objects.create_user( + ''' useful things for creating a status ''' + self.user = models.User.objects.create_user( 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True) - book = models.Edition.objects.create(title='Example Edition') + self.book = models.Edition.objects.create(title='Test Edition') - models.Status.objects.create(user=user, content='Blah blah') - models.Comment.objects.create(user=user, content='content', book=book) - models.Quotation.objects.create( - user=user, content='content', book=book, quote='blah') - models.Review.objects.create( - user=user, content='content', book=book, rating=3) + image_file = pathlib.Path(__file__).parent.joinpath( + '../../static/images/default_avi.jpg') + image = Image.open(image_file) + output = BytesIO() + image.save(output, format=image.format) + self.book.cover.save( + 'test.jpg', + ContentFile(output.getvalue()) + ) - def test_status(self): - status = models.Status.objects.first() + def test_status_generated_fields(self): + ''' setting remote id ''' + status = models.Status.objects.create(content='bleh', user=self.user) expected_id = 'https://%s/user/mouse/status/%d' % \ (settings.DOMAIN, status.id) self.assertEqual(status.remote_id, expected_id) + self.assertEqual(status.privacy, 'public') - def test_comment(self): - comment = models.Comment.objects.first() - expected_id = 'https://%s/user/mouse/comment/%d' % \ - (settings.DOMAIN, comment.id) - self.assertEqual(comment.remote_id, expected_id) + def test_replies(self): + ''' get a list of replies ''' + parent = models.Status.objects.create(content='hi', user=self.user) + child = models.Status.objects.create( + content='hello', reply_parent=parent, user=self.user) + models.Review.objects.create( + content='hey', reply_parent=parent, user=self.user, book=self.book) + models.Status.objects.create( + content='hi hello', reply_parent=child, user=self.user) - def test_quotation(self): - quotation = models.Quotation.objects.first() - expected_id = 'https://%s/user/mouse/quotation/%d' % \ - (settings.DOMAIN, quotation.id) - self.assertEqual(quotation.remote_id, expected_id) + replies = models.Status.replies(parent) + self.assertEqual(replies.count(), 2) + self.assertEqual(replies.first(), child) + # should select subclasses + self.assertIsInstance(replies.last(), models.Review) - def test_review(self): - review = models.Review.objects.first() - expected_id = 'https://%s/user/mouse/review/%d' % \ - (settings.DOMAIN, review.id) - self.assertEqual(review.remote_id, expected_id) + def test_status_type(self): + ''' class name ''' + self.assertEqual(models.Status().status_type, 'Note') + self.assertEqual(models.Review().status_type, 'Review') + self.assertEqual(models.Quotation().status_type, 'Quotation') + self.assertEqual(models.Comment().status_type, 'Comment') + self.assertEqual(models.Boost().status_type, 'Boost') + + def test_boostable(self): + ''' can a status be boosted, based on privacy ''' + self.assertTrue(models.Status(privacy='public').boostable) + self.assertTrue(models.Status(privacy='unlisted').boostable) + self.assertFalse(models.Status(privacy='followers').boostable) + self.assertFalse(models.Status(privacy='direct').boostable) + + def test_to_replies(self): + ''' activitypub replies collection ''' + parent = models.Status.objects.create(content='hi', user=self.user) + child = models.Status.objects.create( + content='hello', reply_parent=parent, user=self.user) + models.Review.objects.create( + content='hey', reply_parent=parent, user=self.user, book=self.book) + models.Status.objects.create( + content='hi hello', reply_parent=child, user=self.user) + + replies = parent.to_replies() + self.assertEqual(replies['id'], '%s/replies' % parent.remote_id) + self.assertEqual(replies['totalItems'], 2) + + def test_status_to_activity(self): + ''' subclass of the base model version with a "pure" serializer ''' + status = models.Status.objects.create( + content='test content', user=self.user) + activity = status.to_activity() + self.assertEqual(activity['id'], status.remote_id) + self.assertEqual(activity['type'], 'Note') + self.assertEqual(activity['content'], 'test content') + self.assertEqual(activity['sensitive'], False) + + def test_status_to_activity_tombstone(self): + ''' subclass of the base model version with a "pure" serializer ''' + status = models.Status.objects.create( + content='test content', user=self.user, + deleted=True, deleted_date=timezone.now()) + activity = status.to_activity() + self.assertEqual(activity['id'], status.remote_id) + self.assertEqual(activity['type'], 'Tombstone') + self.assertFalse(hasattr(activity, 'content')) + + def test_status_to_pure_activity(self): + ''' subclass of the base model version with a "pure" serializer ''' + status = models.Status.objects.create( + content='test content', user=self.user) + activity = status.to_activity(pure=True) + self.assertEqual(activity['id'], status.remote_id) + self.assertEqual(activity['type'], 'Note') + self.assertEqual(activity['content'], 'test content') + self.assertEqual(activity['sensitive'], False) + self.assertEqual(activity['attachment'], []) + + def test_generated_note_to_activity(self): + ''' subclass of the base model version with a "pure" serializer ''' + status = models.GeneratedNote.objects.create( + content='test content', user=self.user) + status.mention_books.set([self.book]) + status.mention_users.set([self.user]) + activity = status.to_activity() + self.assertEqual(activity['id'], status.remote_id) + self.assertEqual(activity['type'], 'GeneratedNote') + self.assertEqual(activity['content'], 'test content') + self.assertEqual(activity['sensitive'], False) + self.assertEqual(len(activity['tag']), 2) + + def test_generated_note_to_pure_activity(self): + ''' subclass of the base model version with a "pure" serializer ''' + status = models.GeneratedNote.objects.create( + content='test content', user=self.user) + status.mention_books.set([self.book]) + status.mention_users.set([self.user]) + activity = status.to_activity(pure=True) + self.assertEqual(activity['id'], status.remote_id) + self.assertEqual( + activity['content'], + 'mouse test content "Test Edition"' % \ + 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

(comment on "Test Edition")

' % + 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

-- "Test Edition"

' \ + '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') diff --git a/bookwyrm/tests/test_incoming.py b/bookwyrm/tests/test_incoming.py index d8e85ef2..7e58da86 100644 --- a/bookwyrm/tests/test_incoming.py +++ b/bookwyrm/tests/test_incoming.py @@ -410,7 +410,10 @@ class Incoming(TestCase): 'actor': self.remote_user.remote_id, 'object': self.status.to_activity(), } - incoming.handle_boost(activity) + with patch('bookwyrm.models.status.Status.ignore_activity') \ + as discarder: + discarder.return_value = False + incoming.handle_boost(activity) boost = models.Boost.objects.get() self.assertEqual(boost.boosted_status, self.status) notification = models.Notification.objects.get() @@ -433,13 +436,35 @@ class Incoming(TestCase): boosted_status=self.status, user=self.remote_user) 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): ''' update an existing user ''' datafile = pathlib.Path(__file__).parent.joinpath( 'data/ap_user.json') userdata = json.loads(datafile.read_bytes()) del userdata['icon'] - self.assertEqual(self.local_user.name, '') + self.assertIsNone(self.local_user.name) incoming.handle_update_user({'object': userdata}) user = models.User.objects.get(id=self.local_user.id) self.assertEqual(user.name, 'MOUSE?? MOUSE!!') @@ -451,11 +476,14 @@ class Incoming(TestCase): 'data/fr_edition.json') 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( title='Test Book', remote_id='https://bookwyrm.social/book/5989') del bookdata['authors'] self.assertEqual(book.title, 'Test Book') + with patch( 'bookwyrm.activitypub.base_activity.set_related_field.delay'): incoming.handle_update_edition({'object': bookdata}) diff --git a/bookwyrm/tests/test_sanitize_html.py b/bookwyrm/tests/test_sanitize_html.py index 3344a934..58d94311 100644 --- a/bookwyrm/tests/test_sanitize_html.py +++ b/bookwyrm/tests/test_sanitize_html.py @@ -1,34 +1,36 @@ +''' make sure only valid html gets to the app ''' from django.test import TestCase from bookwyrm.sanitize_html import InputHtmlParser - class Sanitizer(TestCase): + ''' sanitizer tests ''' def test_no_html(self): + ''' just text ''' input_text = 'no html ' parser = InputHtmlParser() parser.feed(input_text) output = parser.get_output() self.assertEqual(input_text, output) - def test_valid_html(self): + ''' leave the html untouched ''' input_text = 'yes html' parser = InputHtmlParser() parser.feed(input_text) output = parser.get_output() self.assertEqual(input_text, output) - def test_valid_html_attrs(self): + ''' and don't remove attributes ''' input_text = 'yes html' parser = InputHtmlParser() parser.feed(input_text) output = parser.get_output() self.assertEqual(input_text, output) - def test_invalid_html(self): + ''' remove all html when the html is malformed ''' input_text = 'yes html' parser = InputHtmlParser() parser.feed(input_text) @@ -41,8 +43,8 @@ class Sanitizer(TestCase): output = parser.get_output() self.assertEqual('yes html ', output) - def test_disallowed_html(self): + ''' remove disallowed html but keep allowed html ''' input_text = '
yes html
' parser = InputHtmlParser() parser.feed(input_text) diff --git a/bookwyrm/tests/test_templatetags.py b/bookwyrm/tests/test_templatetags.py index 67d28848..6956553e 100644 --- a/bookwyrm/tests/test_templatetags.py +++ b/bookwyrm/tests/test_templatetags.py @@ -158,34 +158,6 @@ class TemplateTags(TestCase): 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): ''' grab it from the edition or the parent ''' work = models.Work.objects.create(title='Test Work') diff --git a/bookwyrm/tests/test_view_actions.py b/bookwyrm/tests/test_view_actions.py new file mode 100644 index 00000000..77584c90 --- /dev/null +++ b/bookwyrm/tests/test_view_actions.py @@ -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) diff --git a/bookwyrm/urls.py b/bookwyrm/urls.py index 3cbb7510..e6c3f79f 100644 --- a/bookwyrm/urls.py +++ b/bookwyrm/urls.py @@ -98,15 +98,16 @@ urlpatterns = [ re_path(r'^edit-profile/?$', actions.edit_profile), - re_path(r'^import-data/?', actions.import_data), - re_path(r'^retry-import/?', actions.retry_import), - re_path(r'^resolve-book/?', actions.resolve_book), - re_path(r'^edit-book/(?P\d+)/?', actions.edit_book), - re_path(r'^upload-cover/(?P\d+)/?', actions.upload_cover), - re_path(r'^add-description/(?P\d+)/?', actions.add_description), + re_path(r'^import-data/?$', actions.import_data), + re_path(r'^retry-import/?$', actions.retry_import), + re_path(r'^resolve-book/?$', actions.resolve_book), + re_path(r'^edit-book/(?P\d+)/?$', actions.edit_book), + re_path(r'^upload-cover/(?P\d+)/?$', actions.upload_cover), + re_path(r'^add-description/(?P\d+)/?$', actions.add_description), - re_path(r'^edit-readthrough/?', actions.edit_readthrough), - re_path(r'^delete-readthrough/?', actions.delete_readthrough), + re_path(r'^switch-edition/?$', actions.switch_edition), + re_path(r'^edit-readthrough/?$', actions.edit_readthrough), + re_path(r'^delete-readthrough/?$', actions.delete_readthrough), re_path(r'^rate/?$', actions.rate), re_path(r'^review/?$', actions.review), diff --git a/bookwyrm/view_actions.py b/bookwyrm/view_actions.py index 7126b1b2..c3e4d2be 100644 --- a/bookwyrm/view_actions.py +++ b/bookwyrm/view_actions.py @@ -10,15 +10,15 @@ from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required, permission_required from django.core.exceptions import PermissionDenied from django.core.files.base import ContentFile +from django.db import transaction from django.http import HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect from django.template.response import TemplateResponse from django.utils import timezone from django.views.decorators.http import require_GET, require_POST -from bookwyrm import books_manager -from bookwyrm import forms, models, outgoing -from bookwyrm import goodreads_import +from bookwyrm import books_manager, forms, models, outgoing, goodreads_import +from bookwyrm.broadcast import broadcast from bookwyrm.emailing import password_reset_email from bookwyrm.settings import DOMAIN from bookwyrm.views import get_user_from_username @@ -66,7 +66,7 @@ def register(request): if not form.is_valid(): errors = True - username = form.data['username'] + username = form.data['username'].strip() email = form.data['email'] password = form.data['password'] @@ -215,11 +215,14 @@ def edit_profile(request): return redirect('/user/%s' % request.user.localname) +@require_POST def resolve_book(request): ''' figure out the local path to a book from a remote_id ''' remote_id = request.POST.get('remote_id') connector = books_manager.get_or_create_connector(remote_id) book = connector.get_or_create_book(remote_id) + if book.connector: + books_manager.load_more_data.delay(book.id) return redirect('/book/%d' % book.id) @@ -239,12 +242,42 @@ def edit_book(request, book_id): 'form': form } return TemplateResponse(request, 'edit_book.html', data) - form.save() + book = form.save() outgoing.handle_update_book(request.user, book) return redirect('/book/%s' % book.id) +@login_required +@require_POST +@transaction.atomic +def switch_edition(request): + ''' switch your copy of a book to a different edition ''' + edition_id = request.POST.get('edition') + new_edition = get_object_or_404(models.Edition, id=edition_id) + shelfbooks = models.ShelfBook.objects.filter( + book__parent_work=new_edition.parent_work, + shelf__user=request.user + ) + for shelfbook in shelfbooks.all(): + broadcast(request.user, shelfbook.to_remove_activity(request.user)) + + shelfbook.book = new_edition + shelfbook.save() + + broadcast(request.user, shelfbook.to_add_activity(request.user)) + + readthroughs = models.ReadThrough.objects.filter( + book__parent_work=new_edition.parent_work, + user=request.user + ) + for readthrough in readthroughs.all(): + readthrough.book = new_edition + readthrough.save() + + return redirect('/book/%d' % new_edition.id) + + @login_required @require_POST def upload_cover(request, book_id): diff --git a/bookwyrm/views.py b/bookwyrm/views.py index f32638d4..8e40f362 100644 --- a/bookwyrm/views.py +++ b/bookwyrm/views.py @@ -594,8 +594,7 @@ def book_page(request, book_id): prev_page = '/book/%s/?page=%d' % \ (book_id, reviews_page.previous_page_number()) - user_tags = [] - readthroughs = [] + user_tags = readthroughs = user_shelves = other_edition_shelves = [] if request.user.is_authenticated: user_tags = models.UserTag.objects.filter( book=book, user=request.user @@ -606,19 +605,26 @@ def book_page(request, book_id): book=book, ).order_by('start_date') - rating = reviews.aggregate(Avg('rating')) - tags = models.UserTag.objects.filter( - book=book, - ) + user_shelves = models.ShelfBook.objects.filter( + added_by=request.user, book=book + ) + + other_edition_shelves = models.ShelfBook.objects.filter( + ~Q(book=book), + added_by=request.user, + book__parent_work=book.parent_work, + ) data = { 'title': book.title, 'book': book, 'reviews': reviews_page, 'ratings': reviews.filter(content__isnull=True), - 'rating': rating['rating__avg'], - 'tags': tags, + 'rating': reviews.aggregate(Avg('rating'))['rating__avg'], + 'tags': models.UserTag.objects.filter(book=book), 'user_tags': user_tags, + 'user_shelves': user_shelves, + 'other_edition_shelves': other_edition_shelves, 'readthroughs': readthroughs, 'path': '/book/%s' % book_id, 'info_fields': [ @@ -662,10 +668,9 @@ def editions_page(request, book_id): encoder=ActivityEncoder ) - editions = models.Edition.objects.filter(parent_work=work).all() data = { 'title': 'Editions of %s' % work.title, - 'editions': editions, + 'editions': work.editions.all(), 'work': work, } return TemplateResponse(request, 'editions.html', data) @@ -679,7 +684,8 @@ def author_page(request, author_id): if is_api_request(request): 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 = { 'title': author.name, 'author': author, @@ -751,7 +757,7 @@ def shelf_page(request, username, shelf_identifier): return JsonResponse(shelf.to_activity(**request.GET)) data = { - 'title': user.name, + 'title': '%s\'s %s shelf' % (user.display_name, shelf.name), 'user': user, 'is_self': is_self, 'shelves': shelves.all(), diff --git a/bw-dev b/bw-dev index 53c8e52d..bf5e8f75 100755 --- a/bw-dev +++ b/bw-dev @@ -57,7 +57,8 @@ case "$1" in clean ;; makemigrations) - execweb python manage.py makemigrations + shift 1 + execweb python manage.py makemigrations "$@" ;; migrate) execweb python manage.py rename_app fedireads bookwyrm diff --git a/requirements.txt b/requirements.txt index 0e17fcca..e5d7798d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ Django==3.0.7 django-model-utils==4.0.0 environs==7.2.0 flower==0.9.4 +Markdown==3.3.3 Pillow>=7.1.0 psycopg2==2.8.4 pycryptodome==3.9.4