Merge branch 'main' into validate-username

This commit is contained in:
Mouse Reeve 2021-01-04 09:41:17 -08:00
commit 50f61f5d19
66 changed files with 2362 additions and 552 deletions

2
.github/FUNDING.yml vendored
View file

@ -1,7 +1,7 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: bookwrym
patreon: bookwyrm
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel

View file

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

View file

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

View file

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

View file

@ -2,3 +2,5 @@
from .settings import CONNECTORS
from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image
from .connector_manager import search, local_search, first_search_result

View file

@ -1,20 +1,18 @@
''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod
from dataclasses import dataclass
from dataclasses import asdict, dataclass
import logging
from urllib3.exceptions import RequestError
from django.db import transaction
import requests
from requests import HTTPError
from requests.exceptions import SSLError
from bookwyrm import activitypub, models
class ConnectorException(HTTPError):
''' when the connector can't do what was asked '''
from bookwyrm import activitypub, models, settings
from .connector_manager import load_more_data, ConnectorException
logger = logging.getLogger(__name__)
class AbstractMinimalConnector(ABC):
''' just the bare bones, for other bookwyrm instances '''
def __init__(self, identifier):
@ -42,11 +40,16 @@ class AbstractMinimalConnector(ABC):
'%s%s' % (self.search_url, query),
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
},
)
if not resp.ok:
resp.raise_for_status()
data = resp.json()
try:
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException('Unable to parse json response', e)
results = []
for doc in self.parse_search_data(data)[:10]:
@ -83,7 +86,6 @@ class AbstractConnector(AbstractMinimalConnector):
return True
@transaction.atomic
def get_or_create_book(self, remote_id):
''' translate arbitrary json into an Activitypub dataclass '''
# first, check if we have the origin_id saved
@ -116,13 +118,17 @@ class AbstractConnector(AbstractMinimalConnector):
if not work_data or not edition_data:
raise ConnectorException('Unable to load book data: %s' % remote_id)
# 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)
with transaction.atomic():
# 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)
edition = self.create_edition_from_data(work, edition_data)
load_more_data.delay(self.connector.id, work.id)
return edition
def create_edition_from_data(self, work, edition_data):
@ -168,7 +174,7 @@ class AbstractConnector(AbstractMinimalConnector):
''' every work needs at least one edition '''
@abstractmethod
def get_work_from_edition_date(self, data):
def get_work_from_edition_data(self, data):
''' every edition needs a work '''
@abstractmethod
@ -196,9 +202,10 @@ def get_data(url):
url,
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
},
)
except RequestError:
except (RequestError, SSLError):
raise ConnectorException()
if not resp.ok:
resp.raise_for_status()
@ -213,7 +220,12 @@ def get_data(url):
def get_image(url):
''' wrapper for requesting an image '''
try:
resp = requests.get(url)
resp = requests.get(
url,
headers={
'User-Agent': settings.USER_AGENT,
},
)
except (RequestError, SSLError):
return None
if not resp.ok:
@ -228,12 +240,19 @@ class SearchResult:
key: str
author: str
year: str
connector: object
confidence: int = 1
def __repr__(self):
return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author)
def json(self):
''' serialize a connector for json response '''
serialized = asdict(self)
del serialized['connector']
return serialized
class Mapping:
''' associate a local database field with a field in an external dataset '''

View file

@ -13,4 +13,5 @@ class Connector(AbstractMinimalConnector):
return data
def format_search_result(self, search_result):
search_result['connector'] = self
return SearchResult(**search_result)

View file

@ -1,4 +1,4 @@
''' select and call a connector for whatever book task needs doing '''
''' interface with whatever connectors the app has '''
import importlib
from urllib.parse import urlparse
@ -8,43 +8,8 @@ from bookwyrm import models
from bookwyrm.tasks import app
def get_edition(book_id):
''' look up a book in the db and return an edition '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if isinstance(book, models.Work):
book = book.default_edition
return book
def get_or_create_connector(remote_id):
''' get the connector related to the author's server '''
url = urlparse(remote_id)
identifier = url.netloc
if not identifier:
raise ValueError('Invalid remote id')
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file='bookwyrm_connector',
base_url='https://%s' % identifier,
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
priority=2
)
return load_connector(connector_info)
@app.task
def load_more_data(book_id):
''' background the work of getting all 10,000 editions of LoTR '''
book = models.Book.objects.select_subclasses().get(id=book_id)
connector = load_connector(book.connector)
connector.expand_book_data(book)
class ConnectorException(HTTPError):
''' when the connector can't do what was asked '''
def search(query, min_confidence=0.1):
@ -55,7 +20,7 @@ def search(query, min_confidence=0.1):
for connector in get_connectors():
try:
result_set = connector.search(query, min_confidence=min_confidence)
except HTTPError:
except (HTTPError, ConnectorException):
continue
result_set = [r for r in result_set \
@ -91,6 +56,38 @@ def get_connectors():
yield load_connector(info)
def get_or_create_connector(remote_id):
''' get the connector related to the author's server '''
url = urlparse(remote_id)
identifier = url.netloc
if not identifier:
raise ValueError('Invalid remote id')
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file='bookwyrm_connector',
base_url='https://%s' % identifier,
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
priority=2
)
return load_connector(connector_info)
@app.task
def load_more_data(connector_id, book_id):
''' background the work of getting all 10,000 editions of LoTR '''
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
book = models.Book.objects.select_subclasses().get(id=book_id)
connector.expand_book_data(book)
def load_connector(connector_info):
''' instantiate the connector class '''
connector = importlib.import_module(

View file

@ -3,7 +3,8 @@ import re
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import ConnectorException, get_data
from .abstract_connector import get_data
from .connector_manager import ConnectorException
from .openlibrary_languages import languages
@ -68,7 +69,7 @@ class Connector(AbstractConnector):
key = data['key']
except KeyError:
raise ConnectorException('Invalid book data')
return '%s/%s' % (self.books_url, key)
return '%s%s' % (self.books_url, key)
def is_work_data(self, data):
@ -80,17 +81,17 @@ class Connector(AbstractConnector):
key = data['key']
except KeyError:
raise ConnectorException('Invalid book data')
url = '%s/%s/editions' % (self.books_url, key)
url = '%s%s/editions' % (self.books_url, key)
data = get_data(url)
return pick_default_edition(data['entries'])
def get_work_from_edition_date(self, data):
def get_work_from_edition_data(self, data):
try:
key = data['works'][0]['key']
except (IndexError, KeyError):
raise ConnectorException('No work found for edition')
url = '%s/%s' % (self.books_url, key)
url = '%s%s' % (self.books_url, key)
return get_data(url)
@ -100,14 +101,14 @@ class Connector(AbstractConnector):
author_blob = author_blob.get('author', author_blob)
# this id is "/authors/OL1234567A"
author_id = author_blob['key']
url = '%s/%s.json' % (self.base_url, author_id)
url = '%s%s' % (self.base_url, author_id)
yield self.get_or_create_author(url)
def get_cover_url(self, cover_blob):
''' ask openlibrary for the cover '''
cover_id = cover_blob[0]
image_name = '%s-M.jpg' % cover_id
image_name = '%s-L.jpg' % cover_id
return '%s/b/id/%s' % (self.covers_url, image_name)
@ -123,13 +124,14 @@ class Connector(AbstractConnector):
title=search_result.get('title'),
key=key,
author=', '.join(author),
connector=self,
year=search_result.get('first_publish_year'),
)
def load_edition_data(self, olkey):
''' query openlibrary for editions of a work '''
url = '%s/works/%s/editions.json' % (self.books_url, olkey)
url = '%s/works/%s/editions' % (self.books_url, olkey)
return get_data(url)
@ -149,7 +151,7 @@ def get_description(description_blob):
''' descriptions can be a string or a dict '''
if isinstance(description_blob, dict):
return description_blob.get('value')
return description_blob
return description_blob
def get_openlibrary_key(key):

View file

@ -1,6 +1,9 @@
''' using a bookwyrm instance as a source of book data '''
from functools import reduce
import operator
from django.contrib.postgres.search import SearchRank, SearchVector
from django.db.models import F
from django.db.models import Count, F, Q
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult
@ -9,38 +12,18 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector):
''' instantiate a connector '''
def search(self, query, min_confidence=0.1):
''' right now you can't search bookwyrm sorry, but when
that gets implemented it will totally rule '''
vector = SearchVector('title', weight='A') +\
SearchVector('subtitle', weight='B') +\
SearchVector('authors__name', weight='C') +\
SearchVector('isbn_13', weight='A') +\
SearchVector('isbn_10', weight='A') +\
SearchVector('openlibrary_key', weight='C') +\
SearchVector('goodreads_key', weight='C') +\
SearchVector('asin', weight='C') +\
SearchVector('oclc_number', weight='C') +\
SearchVector('remote_id', weight='C') +\
SearchVector('description', weight='D') +\
SearchVector('series', weight='D')
results = models.Edition.objects.annotate(
search=vector
).annotate(
rank=SearchRank(vector, query)
).filter(
rank__gt=min_confidence
).order_by('-rank')
# remove non-default editions, if possible
results = results.filter(parent_work__default_edition__id=F('id')) \
or results
''' search your local database '''
# first, try searching unqiue identifiers
results = search_identifiers(query)
if not results:
# then try searching title/author
results = search_title_author(query, min_confidence)
search_results = []
for book in results[:10]:
search_results.append(
self.format_search_result(book)
)
for result in results:
search_results.append(self.format_search_result(result))
if len(search_results) >= 10:
break
search_results.sort(key=lambda r: r.confidence, reverse=True)
return search_results
@ -51,31 +34,74 @@ class Connector(AbstractConnector):
author=search_result.author_text,
year=search_result.published_date.year if \
search_result.published_date else None,
confidence=search_result.rank,
connector=self,
confidence=search_result.rank if \
hasattr(search_result, 'rank') else 1,
)
def get_remote_id_from_data(self, data):
pass
def is_work_data(self, data):
pass
def get_edition_from_work_data(self, data):
pass
def get_work_from_edition_date(self, data):
def get_work_from_edition_data(self, data):
pass
def get_authors_from_data(self, data):
return None
def get_cover_from_data(self, data):
return None
def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it '''
return data
def expand_book_data(self, book):
pass
def search_identifiers(query):
''' tries remote_id, isbn; defined as dedupe fields on the model '''
filters = [{f.name: query} for f in models.Edition._meta.get_fields() \
if hasattr(f, 'deduplication_field') and f.deduplication_field]
results = models.Edition.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters))
).distinct()
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
return results.filter(parent_work__default_edition__id=F('id')) \
or results
def search_title_author(query, min_confidence):
''' searches for title and author '''
vector = SearchVector('title', weight='A') +\
SearchVector('subtitle', weight='B') +\
SearchVector('authors__name', weight='C') +\
SearchVector('series', weight='D')
results = models.Edition.objects.annotate(
search=vector
).annotate(
rank=SearchRank(vector, query)
).filter(
rank__gt=min_confidence
).order_by('-rank')
# when there are multiple editions of the same work, pick the closest
editions_of_work = results.values(
'parent_work'
).annotate(
Count('parent_work')
).values_list('parent_work')
for work_id in set(editions_of_work):
editions = results.filter(parent_work=work_id)
default = editions.filter(parent_work__default_edition=F('id'))
default_rank = default.first().rank if default.exists() else 0
# if mutliple books have the top rank, pick the default edition
if default_rank == editions.first().rank:
yield default.first()
else:
yield editions.first()

View file

@ -8,8 +8,6 @@ from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification
logger = logging.getLogger(__name__)
# TODO: remove or increase once we're confident it's not causing problems.
MAX_ENTRIES = 500
def create_job(user, csv_file, include_reviews, privacy):
@ -19,12 +17,13 @@ def create_job(user, csv_file, include_reviews, privacy):
include_reviews=include_reviews,
privacy=privacy
)
for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]):
for index, entry in enumerate(list(csv.DictReader(csv_file))):
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
raise ValueError('Author, title, and isbn must be in data.')
ImportItem(job=job, index=index, data=entry).save()
return job
def create_retry_job(user, original_job, items):
''' retry items that didn't import '''
job = ImportJob.objects.create(
@ -37,6 +36,7 @@ def create_retry_job(user, original_job, items):
ImportItem(job=job, index=item.index, data=item.data).save()
return job
def start_import(job):
''' initalizes a csv import job '''
result = import_data.delay(job.id)
@ -49,7 +49,6 @@ def import_data(job_id):
''' does the actual lookup work in a celery task '''
job = ImportJob.objects.get(id=job_id)
try:
results = []
for item in job.items.all():
try:
item.resolve()
@ -61,7 +60,6 @@ def import_data(job_id):
if item.book:
item.save()
results.append(item)
# shelves book and handles reviews
outgoing.handle_imported_book(

View file

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

View file

@ -0,0 +1,83 @@
''' PROCEED WITH CAUTION: uses deduplication fields to permanently
merge book data objects '''
from django.core.management.base import BaseCommand
from django.db.models import Count
from bookwyrm import models
def update_related(canonical, obj):
''' update all the models with fk to the object being removed '''
# move related models to canonical
related_models = [
(r.remote_field.name, r.related_model) for r in \
canonical._meta.related_objects]
for (related_field, related_model) in related_models:
related_objs = related_model.objects.filter(
**{related_field: obj})
for related_obj in related_objs:
print(
'replacing in',
related_model.__name__,
related_field,
related_obj.id
)
try:
setattr(related_obj, related_field, canonical)
related_obj.save()
except TypeError:
getattr(related_obj, related_field).add(canonical)
getattr(related_obj, related_field).remove(obj)
def copy_data(canonical, obj):
''' try to get the most data possible '''
for data_field in obj._meta.get_fields():
if not hasattr(data_field, 'activitypub_field'):
continue
data_value = getattr(obj, data_field.name)
if not data_value:
continue
if not getattr(canonical, data_field.name):
print('setting data field', data_field.name, data_value)
setattr(canonical, data_field.name, data_value)
canonical.save()
def dedupe_model(model):
''' combine duplicate editions and update related models '''
fields = model._meta.get_fields()
dedupe_fields = [f for f in fields if \
hasattr(f, 'deduplication_field') and f.deduplication_field]
for field in dedupe_fields:
dupes = model.objects.values(field.name).annotate(
Count(field.name)
).filter(**{'%s__count__gt' % field.name: 1})
for dupe in dupes:
value = dupe[field.name]
if not value or value == '':
continue
print('----------')
print(dupe)
objs = model.objects.filter(
**{field.name: value}
).order_by('id')
canonical = objs.first()
print('keeping', canonical.remote_id)
for obj in objs[1:]:
print(obj.remote_id)
copy_data(canonical, obj)
update_related(canonical, obj)
# remove the outdated entry
obj.delete()
class Command(BaseCommand):
''' dedplucate allllll the book data models '''
help = 'merges duplicate book data'
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
''' run deudplications '''
dedupe_model(models.Edition)
dedupe_model(models.Work)
dedupe_model(models.Author)

View file

@ -9,8 +9,11 @@ from .connector import Connector
from .shelf import Shelf, ShelfBook
from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough
from .status import Boost
from .attachment import Image
from .favorite import Favorite
from .notification import Notification
from .readthrough import ReadThrough
from .tag import Tag, UserTag
@ -25,3 +28,6 @@ from .site import SiteSettings, SiteInvite, PasswordReset
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[1].activity_serializer.__name__: c[1] \
for c in cls_members if hasattr(c[1], 'activity_serializer')}
status_models = [
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]

View file

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

View file

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

View file

@ -0,0 +1,26 @@
''' like/fav/star a status '''
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_serializer = activitypub.Like
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')

View file

@ -6,7 +6,7 @@ from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
from bookwyrm import books_manager
from bookwyrm.connectors import connector_manager
from bookwyrm.models import ReadThrough, User, Book
from .fields import PrivacyLevels
@ -71,12 +71,12 @@ class ImportItem(models.Model):
def get_book_from_isbn(self):
''' search by isbn '''
search_result = books_manager.first_search_result(
search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999
)
if search_result:
# raises ConnectorException
return books_manager.get_or_create_book(search_result.key)
return search_result.connector.get_or_create_book(search_result.key)
return None
@ -86,12 +86,12 @@ class ImportItem(models.Model):
self.data['Title'],
self.data['Author']
)
search_result = books_manager.first_search_result(
search_result = connector_manager.first_search_result(
search_term, min_confidence=0.999
)
if search_result:
# raises ConnectorException
return books_manager.get_or_create_book(search_result.key)
return search_result.connector.get_or_create_book(search_result.key)
return None

View file

@ -0,0 +1,33 @@
''' alert a user to activity '''
from django.db import models
from .base_model import BookWyrmModel
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
)
]

View file

@ -0,0 +1,26 @@
''' progress in a book '''
from django.db import models
from django.utils import timezone
from .base_model import BookWyrmModel
class ReadThrough(BookWyrmModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
pages_read = models.IntegerField(
null=True,
blank=True)
start_date = models.DateTimeField(
blank=True,
null=True)
finish_date = models.DateTimeField(
blank=True,
null=True)
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)

View file

@ -54,7 +54,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
def to_reject_activity(self):
''' generate an Accept for this follow request '''
''' generate a Reject for this follow request '''
return activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,

View file

@ -118,7 +118,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
activity['attachment'] = [
image_serializer(b.cover, b.alt_text) \
for b in self.mention_books.all()[:4] if b.cover]
if hasattr(self, 'book'):
if hasattr(self, 'book') and self.book.cover:
activity['attachment'].append(
image_serializer(self.book.cover, self.book.alt_text)
)
@ -222,26 +222,6 @@ class Review(Status):
pure_type = 'Article'
class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_serializer = activitypub.Like
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')
class Boost(Status):
''' boost'ing a post '''
boosted_status = fields.ForeignKey(
@ -268,54 +248,3 @@ class Boost(Status):
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
class ReadThrough(BookWyrmModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
pages_read = models.IntegerField(
null=True,
blank=True)
start_date = models.DateTimeField(
blank=True,
null=True)
finish_date = models.DateTimeField(
blank=True,
null=True)
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
)
]

View file

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

View file

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

View file

@ -2,8 +2,10 @@
import re
from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from markdown import markdown
from requests import HTTPError
@ -20,19 +22,16 @@ from bookwyrm.utils import regex
@csrf_exempt
@require_GET
def outbox(request, username):
''' outbox for the requested user '''
if request.method != 'GET':
return HttpResponseNotFound()
user = get_object_or_404(models.User, localname=username)
filter_type = request.GET.get('type')
if filter_type not in models.status_models:
filter_type = None
try:
user = models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
# collection overview
return JsonResponse(
user.to_outbox(**request.GET),
user.to_outbox(**request.GET, filter_type=filter_type),
encoder=activitypub.ActivityEncoder
)
@ -42,6 +41,9 @@ def handle_remote_webfinger(query):
user = None
# usernames could be @user@domain or user@domain
if not query:
return None
if query[0] == '@':
query = query[1:]
@ -164,22 +166,23 @@ def handle_imported_book(user, item, include_reviews, privacy):
if not item.book:
return
if item.shelf:
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, added_by=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
# shelve the book if it hasn't been shelved already
shelf_book, created = models.ShelfBook.objects.get_or_create(
shelf_book = models.ShelfBook.objects.create(
book=item.book, shelf=desired_shelf, added_by=user)
if created:
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
# only add new read-throughs if the item isn't already shelved
for read in item.reads:
read.book = item.book
read.user = user
read.save()
for read in item.reads:
read.book = item.book
read.user = user
read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on Goodreads'.format(
@ -218,8 +221,65 @@ def handle_status(user, form):
status.save()
# inspect the text for user tags
matches = []
for match in re.finditer(regex.username, status.content):
content = status.content
for (mention_text, mention_user) in find_mentions(content):
# add them to status mentions fk
status.mention_users.add(mention_user)
# turn the mention into a link
content = re.sub(
r'%s([^@]|$)' % mention_text,
r'<a href="%s">%s</a>\g<1>' % \
(mention_user.remote_id, mention_text),
content)
# add reply parent to mentions and notify
if status.reply_parent:
status.mention_users.add(status.reply_parent.user)
for mention_user in status.reply_parent.mention_users.all():
status.mention_users.add(mention_user)
if status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=user,
related_status=status
)
# deduplicate mentions
status.mention_users.set(set(status.mention_users.all()))
# create mention notifications
for mention_user in status.mention_users.all():
if status.reply_parent and mention_user == status.reply_parent.user:
continue
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=user,
related_status=status
)
# don't apply formatting to generated notes
if not isinstance(status, models.GeneratedNote):
status.content = to_markdown(content)
# do apply formatting to quotes
if hasattr(status, 'quote'):
status.quote = to_markdown(status.quote)
status.save()
broadcast(user, status.to_create_activity(user), software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_activity, software='other')
def find_mentions(content):
''' detect @mentions in raw status content '''
for match in re.finditer(regex.strict_username, content):
username = match.group().strip().split('@')[1:]
if len(username) == 1:
# this looks like a local user (@user), fill in the domain
@ -230,44 +290,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
if mention_user.local:
create_notification(
mention_user,
'MENTION',
related_user=user,
related_status=status
)
# add mentions
content = status.content
for (username, url) in matches:
content = re.sub(
r'%s([^@])' % username,
r'<a href="%s">%s</a>\g<1>' % (url, username),
content)
if not isinstance(status, models.GeneratedNote):
status.content = to_markdown(content)
if hasattr(status, 'quote'):
status.quote = to_markdown(status.quote)
status.save()
# notify reply parent or tagged users
if status.reply_parent and status.reply_parent.user.local:
create_notification(
status.reply_parent.user,
'REPLY',
related_user=user,
related_status=status
)
broadcast(user, status.to_create_activity(user), software='bookwyrm')
# re-format the activity for non-bookwyrm servers
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_activity, software='other')
yield (match.group(), mention_user)
def to_markdown(content):
@ -284,21 +307,6 @@ def to_markdown(content):
return sanitizer.get_output()
def handle_tag(user, tag):
''' tag a book '''
broadcast(user, tag.to_add_activity(user))
def handle_untag(user, book, name):
''' tag a book '''
book = models.Book.objects.get(id=book)
tag = models.Tag.objects.get(name=name, book=book, user=user)
tag_activity = tag.to_remove_activity(user)
tag.delete()
broadcast(user, tag_activity)
def handle_favorite(user, status):
''' a user likes a status '''
try:

View file

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

View file

@ -67,6 +67,13 @@ input.toggle-control:checked ~ .modal.toggle-content {
width: max-content;
max-width: 250px;
}
.cover-container.is-large {
height: max-content;
max-width: 500px;
}
.cover-container.is-large img {
max-height: 500px;
}
.cover-container.is-medium {
height: 150px;
}

View file

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

View file

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

View file

@ -0,0 +1,80 @@
{% extends 'layout.html' %}
{% block content %}
{% if not request.user.is_authenticated %}
<div class="block">
<h1 class="title has-text-centered">{{ site.name }}: Social Reading and Reviewing</h1>
</div>
<section class="tile is-ancestor">
<div class="tile is-7 is-parent">
<div class="tile is-child box">
{% include 'snippets/about.html' %}
</div>
</div>
<div class="tile is-5 is-parent">
<div class="tile is-child box has-background-primary-light">
{% if site.allow_registration %}
<h2 class="title">Join {{ site.name }}</h2>
<form name="register" method="post" action="/user-register">
{% include 'snippets/register_form.html' %}
</form>
{% else %}
<h2 class="title">This instance is closed</h2>
<p>Contact an administrator to get an invite</p>
{% endif %}
</div>
</div>
</section>
{% else %}
<div class="block">
<h1 class="title has-text-centered">Discover</h1>
</div>
{% endif %}
<div class="block is-hidden-tablet">
<h2 class="title has-text-centered">Recent Books</h2>
</div>
<section class="tile is-ancestor">
<div class="tile is-vertical">
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.0 %}
</div>
</div>
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.1 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.2 %}
</div>
</div>
</div>
</div>
<div class="tile is-vertical">
<div class="tile">
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.3 %}
</div>
</div>
<div class="tile is-parent is-6">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/small-book.html' with book=books.4 %}
</div>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box has-background-white-ter">
{% include 'snippets/discover/large-book.html' with book=books.5 %}
</div>
</div>
</div>
</section>
{% endblock %}

View file

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

View file

@ -21,8 +21,6 @@
</div>
<button class="button is-primary" type="submit">Import</button>
</form>
<p>
Imports are limited in size, and only the first {{ limit }} items will be imported.
</div>
<div class="content block">

View file

@ -18,7 +18,7 @@
</head>
<body>
<nav class="navbar" role="navigation" aria-label="main navigation">
<nav class="navbar container" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="/static/images/logo-small.png" alt="Home page">
@ -63,33 +63,45 @@
<div class="navbar-end">
{% if request.user.is_authenticated %}
<div class="navbar-item has-dropdown is-hoverable">
<div class="navbar-link"><p>
<div class="navbar-link" role="button" aria-expanded=false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
{% include 'snippets/avatar.html' with user=request.user %}
{% include 'snippets/username.html' with user=request.user %}
</p></div>
<div class="navbar-dropdown">
<a href="/direct-messages" class="navbar-item">
Direct messages
</a>
<a href="/user/{{request.user.localname}}" class="navbar-item">
Profile
</a>
<a href="/user-edit" class="navbar-item">
Settings
</a>
<a href="/import" class="navbar-item">
Import books
</a>
<ul class="navbar-dropdown" id="navbar-dropdown">
<li>
<a href="/direct-messages" class="navbar-item">
Direct messages
</a>
</li>
<li>
<a href="/user/{{request.user.localname}}" class="navbar-item">
Profile
</a>
</li>
<li>
<a href="/user-edit" class="navbar-item">
Settings
</a>
</li>
<li>
<a href="/import" class="navbar-item">
Import books
</a>
</li>
{% if perms.bookwyrm.create_invites %}
<a href="/invite" class="navbar-item">
Invites
</a>
<li>
<a href="/invite" class="navbar-item">
Invites
</a>
</li>
{% endif %}
<hr class="navbar-divider">
<a href="/logout" class="navbar-item">
Log out
</a>
</div>
<li>
<a href="/logout" class="navbar-item">
Log out
</a>
</li>
</ul>
</div>
<div class="navbar-item">
<a href="/notifications">
@ -105,48 +117,73 @@
</div>
</a>
</div>
{% else %}
{% else %}
<div class="navbar-item">
<div class="buttons">
<a href="/login" class="button is-primary">
Join
</a>
{% if request.path != '/login' and request.path != '/login/' %}
<div class="columns">
<div class="column">
<form name="login" method="post" action="/user-login">
{% csrf_token %}
<div class="field is-grouped">
<div class="control">
<label class="is-sr-only" for="id_username">Username:</label>
<input type="text" name="username" maxlength="150" class="input" required="" id="id_username" placeholder="username">
</div>
<div class="control">
<label class="is-sr-only" for="id_password">Username:</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="password">
<p class="help"><a href="/password-reset">Forgot your password?</a></p>
</div>
<button class="button is-primary" type="submit">Log in</button>
</div>
</form>
</div>
{% if site.allow_registration and request.path != '' and request.path != '/' %}
<div class="column is-narrow">
<a href="/" class="button is-link">
Join
</a>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
</div>
</div>
</nav>
<div class="section">
<div class="section container">
{% block content %}
{% endblock %}
</div>
<div class="footer">
<div class="columns">
<div class="column">
<p>
<a href="/about">About this server</a>
</p>
{% if site.admin_email %}
<p>
<a href="mailto:{{ site.admin_email }}">Contact site admin</a>
</p>
<footer class="footer">
<div class="container">
<div class="columns">
<div class="column">
<p>
<a href="/about">About this server</a>
</p>
{% if site.admin_email %}
<p>
<a href="mailto:{{ site.admin_email }}">Contact site admin</a>
</p>
{% endif %}
</div>
{% if site.support_link %}
<div class="column">
<span class="icon icon-heart"></span>
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
</div>
{% endif %}
</div>
{% if site.support_link %}
<div class="column">
<span class="icon icon-heart"></span>
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
</div>
{% endif %}
<div class="column">
BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.
<div class="column">
BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.
</div>
</div>
</div>
</div>
</footer>
<script>
var csrf_token = '{{ csrf_token }}';
@ -154,4 +191,3 @@
<script src="/static/js/shared.js"></script>
</body>
</html>

View file

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

View file

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

View file

@ -1,7 +1,12 @@
<h1 class="title">About {{ site.name }}</h1>
<div class="block">
<img src="/static/images/logo.png" alt="BookWyrm">
<div class="columns">
<div class="column is-narrow is-hidden-mobile">
<figure class="block">
<img src="/static/images/logo.png" alt="BookWyrm">
</figure>
</div>
<div class="content">
<p class="block">
{{ site.instance_description | safe }}
</p>
</div>
</div>
<p class="block">
{{ site.instance_description }}
</p>

View file

@ -0,0 +1,18 @@
{% load bookwyrm_tags %}
{% if book %}
<div class="columns">
<div class="column is-narrow">
{% include 'snippets/book_cover.html' with book=book size="large" %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
</div>
<div class="column">
<h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
{% if book.authors %}
<p class="subtitle is-5">by {% include 'snippets/authors.html' with book=book %}</p>
{% endif %}
{% if book|book_description %}
<blockquote class="content">{{ book|book_description|to_markdown|safe|truncatewords_html:50 }}</blockquote>
{% endif %}
</div>
</div>
{% endif %}

View file

@ -0,0 +1,11 @@
{% load bookwyrm_tags %}
{% if book %}
{% include 'snippets/book_cover.html' with book=book %}
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
{% if book.authors %}
<p class="subtitle is-6">by {% include 'snippets/authors.html' with book=book %}</p>
{% endif %}
{% endif %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,12 +1,18 @@
''' interface between the app and various connectors '''
from django.test import TestCase
from bookwyrm import books_manager, models
from bookwyrm.connectors.bookwyrm_connector import Connector as BookWyrmConnector
from bookwyrm.connectors.self_connector import Connector as SelfConnector
from bookwyrm import models
from bookwyrm.connectors import connector_manager
from bookwyrm.connectors.bookwyrm_connector \
import Connector as BookWyrmConnector
from bookwyrm.connectors.self_connector \
import Connector as SelfConnector
class Book(TestCase):
class ConnectorManager(TestCase):
''' interface between the app and various connectors '''
def setUp(self):
''' we'll need some books and a connector info entry '''
self.work = models.Work.objects.create(
title='Example Work'
)
@ -28,53 +34,50 @@ class Book(TestCase):
covers_url='http://test.com/',
)
def test_get_edition(self):
edition = books_manager.get_edition(self.edition.id)
self.assertEqual(edition, self.edition)
def test_get_edition_work(self):
edition = books_manager.get_edition(self.work.id)
self.assertEqual(edition, self.edition)
def test_get_or_create_connector(self):
''' loads a connector if the data source is known or creates one '''
remote_id = 'https://example.com/object/1'
connector = books_manager.get_or_create_connector(remote_id)
connector = connector_manager.get_or_create_connector(remote_id)
self.assertIsInstance(connector, BookWyrmConnector)
self.assertEqual(connector.identifier, 'example.com')
self.assertEqual(connector.base_url, 'https://example.com')
same_connector = books_manager.get_or_create_connector(remote_id)
same_connector = connector_manager.get_or_create_connector(remote_id)
self.assertEqual(connector.identifier, same_connector.identifier)
def test_get_connectors(self):
''' load all connectors '''
remote_id = 'https://example.com/object/1'
books_manager.get_or_create_connector(remote_id)
connectors = list(books_manager.get_connectors())
connector_manager.get_or_create_connector(remote_id)
connectors = list(connector_manager.get_connectors())
self.assertEqual(len(connectors), 2)
self.assertIsInstance(connectors[0], SelfConnector)
self.assertIsInstance(connectors[1], BookWyrmConnector)
def test_search(self):
results = books_manager.search('Example')
''' search all connectors '''
results = connector_manager.search('Example')
self.assertEqual(len(results), 1)
self.assertIsInstance(results[0]['connector'], SelfConnector)
self.assertEqual(len(results[0]['results']), 1)
self.assertEqual(results[0]['results'][0].title, 'Example Edition')
def test_local_search(self):
results = books_manager.local_search('Example')
''' search only the local database '''
results = connector_manager.local_search('Example')
self.assertEqual(len(results), 1)
self.assertEqual(results[0].title, 'Example Edition')
def test_first_search_result(self):
result = books_manager.first_search_result('Example')
''' only get one search result '''
result = connector_manager.first_search_result('Example')
self.assertEqual(result.title, 'Example Edition')
no_result = books_manager.first_search_result('dkjfhg')
no_result = connector_manager.first_search_result('dkjfhg')
self.assertIsNone(no_result)
def test_load_connector(self):
connector = books_manager.load_connector(self.connector)
''' load a connector object from the database entry '''
connector = connector_manager.load_connector(self.connector)
self.assertIsInstance(connector, SelfConnector)
self.assertEqual(connector.identifier, 'test_connector')

View file

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

View file

@ -9,7 +9,9 @@ from bookwyrm.settings import DOMAIN
class SelfConnector(TestCase):
''' just uses local data '''
def setUp(self):
''' creating the connector '''
models.Connector.objects.create(
identifier=DOMAIN,
name='Local',
@ -22,58 +24,85 @@ class SelfConnector(TestCase):
priority=1,
)
self.connector = Connector(DOMAIN)
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',
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,
series='Anonymous'
)
models.Edition.objects.create(
title='More Editions',
subtitle='The Anonymous Edition',
parent_work=self.work,
)
edition = models.Edition.objects.create(
title='An Edition',
parent_work=self.work
)
edition.authors.add(models.Author.objects.create(name='Fish'))
def test_format_search_result(self):
''' create a SearchResult '''
author = models.Author.objects.create(name='Anonymous')
edition = models.Edition.objects.create(
title='Edition of Example Work',
published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc),
)
edition.authors.add(author)
result = self.connector.search('Edition of Example')[0]
self.assertEqual(result.title, 'Edition of Example Work')
self.assertEqual(result.key, self.edition.remote_id)
self.assertEqual(result.key, edition.remote_id)
self.assertEqual(result.author, 'Anonymous')
self.assertEqual(result.year, 1980)
self.assertEqual(result.connector, self.connector)
def test_search_rank(self):
''' prioritize certain results '''
author = models.Author.objects.create(name='Anonymous')
edition = models.Edition.objects.create(
title='Edition of Example Work',
published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc),
parent_work=models.Work.objects.create(title='')
)
# author text is rank C
edition.authors.add(author)
# series is rank D
models.Edition.objects.create(
title='Another Edition',
series='Anonymous',
parent_work=models.Work.objects.create(title='')
)
# subtitle is rank B
models.Edition.objects.create(
title='More Editions',
subtitle='The Anonymous Edition',
parent_work=models.Work.objects.create(title='')
)
# title is rank A
models.Edition.objects.create(title='Anonymous')
# doesn't rank in this search
edition = models.Edition.objects.create(
title='An Edition',
parent_work=models.Work.objects.create(title='')
)
results = self.connector.search('Anonymous')
self.assertEqual(len(results), 2)
self.assertEqual(results[0].title, 'More Editions')
self.assertEqual(results[1].title, 'Edition of Example Work')
self.assertEqual(len(results), 3)
self.assertEqual(results[0].title, 'Anonymous')
self.assertEqual(results[1].title, 'More Editions')
self.assertEqual(results[2].title, 'Edition of Example Work')
def test_search_default_filter(self):
def test_search_multiple_editions(self):
''' it should get rid of duplicate editions for the same work '''
self.work.default_edition = self.edition
self.work.save()
work = models.Work.objects.create(title='Work Title')
edition_1 = models.Edition.objects.create(
title='Edition 1 Title', parent_work=work)
edition_2 = models.Edition.objects.create(
title='Edition 2 Title', parent_work=work)
edition_3 = models.Edition.objects.create(
title='Fish', parent_work=work)
work.default_edition = edition_2
work.save()
results = self.connector.search('Anonymous')
# pick the best edition
results = self.connector.search('Edition 1 Title')
self.assertEqual(len(results), 1)
self.assertEqual(results[0].title, 'Edition of Example Work')
self.assertEqual(results[0].key, edition_1.remote_id)
# pick the default edition when no match is best
results = self.connector.search('Edition Title')
self.assertEqual(len(results), 1)
self.assertEqual(results[0].key, edition_2.remote_id)
# only matches one edition, so no deduplication takes place
results = self.connector.search('Fish')
self.assertEqual(len(results), 1)
self.assertEqual(results[0].title, 'An Edition')
self.assertEqual(results[0].key, edition_3.remote_id)

View file

@ -0,0 +1,4 @@
Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Recommended For,Recommended By,Owned Copies,Original Purchase Date,Original Purchase Location,Condition,Condition Description,BCID
42036538,Gideon the Ninth (The Locked Tomb #1),Tamsyn Muir,"Muir, Tamsyn",,"=""1250313198""","=""9781250313195""",0,4.20,Tor,Hardcover,448,2019,2019,2020/10/25,2020/10/21,,,read,,,,1,,,0,,,,,
52691223,Subcutanean,Aaron A. Reed,"Reed, Aaron A.",,"=""""","=""""",0,4.45,,Paperback,232,2020,,2020/03/06,2020/03/05,,,read,,,,1,,,0,,,,,
28694510,Patisserie at Home,Mélanie Dupuis,"Dupuis, Mélanie",Anne Cazor,"=""0062445316""","=""9780062445315""",2,4.60,Harper Design,Hardcover,288,2016,,,2019/07/08,,,read,"mixed feelings",,,2,,,0,,,,,
1 Book Id Title Author Author l-f Additional Authors ISBN ISBN13 My Rating Average Rating Publisher Binding Number of Pages Year Published Original Publication Year Date Read Date Added Bookshelves Bookshelves with positions Exclusive Shelf My Review Spoiler Private Notes Read Count Recommended For Recommended By Owned Copies Original Purchase Date Original Purchase Location Condition Condition Description BCID
2 42036538 Gideon the Ninth (The Locked Tomb #1) Tamsyn Muir Muir, Tamsyn ="1250313198" ="9781250313195" 0 4.20 Tor Hardcover 448 2019 2019 2020/10/25 2020/10/21 read 1 0
3 52691223 Subcutanean Aaron A. Reed Reed, Aaron A. ="" ="" 0 4.45 Paperback 232 2020 2020/03/06 2020/03/05 read 1 0
4 28694510 Patisserie at Home Mélanie Dupuis Dupuis, Mélanie Anne Cazor ="0062445316" ="9780062445315" 2 4.60 Harper Design Hardcover 288 2016 2019/07/08 read mixed feelings 2 0

View file

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

View file

@ -1,9 +1,16 @@
''' testing models '''
import datetime
import json
import pathlib
from unittest.mock import patch
from django.utils import timezone
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.connectors import connector_manager
from bookwyrm.connectors.abstract_connector import SearchResult
class ImportJob(TestCase):
@ -55,11 +62,11 @@ class ImportJob(TestCase):
'mouse', 'mouse@mouse.mouse', 'mouseword',
local=True, localname='mouse')
job = models.ImportJob.objects.create(user=user)
models.ImportItem.objects.create(
self.item_1 = models.ImportItem.objects.create(
job=job, index=1, data=currently_reading_data)
models.ImportItem.objects.create(
self.item_2 = models.ImportItem.objects.create(
job=job, index=2, data=read_data)
models.ImportItem.objects.create(
self.item_3 = models.ImportItem.objects.create(
job=job, index=3, data=unknown_read_data)
@ -73,8 +80,7 @@ class ImportJob(TestCase):
def test_shelf(self):
''' converts to the local shelf typology '''
expected = 'reading'
item = models.ImportItem.objects.get(index=1)
self.assertEqual(item.shelf, expected)
self.assertEqual(self.item_1.shelf, expected)
def test_date_added(self):
@ -92,21 +98,79 @@ class ImportJob(TestCase):
def test_currently_reading_reads(self):
''' infer currently reading dates where available '''
expected = [models.ReadThrough(
start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))]
start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc)
)]
actual = models.ImportItem.objects.get(index=1)
self.assertEqual(actual.reads[0].start_date, expected[0].start_date)
self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date)
def test_read_reads(self):
actual = models.ImportItem.objects.get(index=2)
self.assertEqual(actual.reads[0].start_date, datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))
self.assertEqual(actual.reads[0].finish_date, datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc))
''' infer read dates where available '''
actual = self.item_2
self.assertEqual(
actual.reads[0].start_date,
datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))
self.assertEqual(
actual.reads[0].finish_date,
datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc))
def test_unread_reads(self):
''' handle books with no read dates '''
expected = []
actual = models.ImportItem.objects.get(index=3)
self.assertEqual(actual.reads, expected)
@responses.activate
def test_get_book_from_isbn(self):
''' search and load books by isbn (9780356506999) '''
connector_info = models.Connector.objects.create(
identifier='openlibrary.org',
name='OpenLibrary',
connector_file='openlibrary',
base_url='https://openlibrary.org',
books_url='https://openlibrary.org',
covers_url='https://covers.openlibrary.org',
search_url='https://openlibrary.org/search?q=',
priority=3,
)
connector = connector_manager.load_connector(connector_info)
result = SearchResult(
title='Test Result',
key='https://openlibrary.org/works/OL1234W',
author='An Author',
year='1980',
connector=connector,
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ol_edition.json')
bookdata = json.loads(datafile.read_bytes())
responses.add(
responses.GET,
'https://openlibrary.org/works/OL1234W',
json=bookdata,
status=200)
responses.add(
responses.GET,
'https://openlibrary.org/works/OL15832982W',
json=bookdata,
status=200)
responses.add(
responses.GET,
'https://openlibrary.org/authors/OL382982A',
json={'name': 'test author'},
status=200)
with patch(
'bookwyrm.connectors.abstract_connector.load_more_data.delay'):
with patch(
'bookwyrm.connectors.connector_manager.first_search_result'
) as search:
search.return_value = result
book = self.item_1.get_book_from_isbn()
self.assertEqual(book.title, 'Sabriel')

View file

@ -0,0 +1,104 @@
''' testing import '''
from collections import namedtuple
import pathlib
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import goodreads_import, models
from bookwyrm.settings import DOMAIN
class GoodreadsImport(TestCase):
''' importing from goodreads csv '''
def setUp(self):
''' use a test csv '''
datafile = pathlib.Path(__file__).parent.joinpath(
'data/goodreads.csv')
self.csv = open(datafile, 'r')
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'password', local=True)
models.Connector.objects.create(
identifier=DOMAIN,
name='Local',
local=True,
connector_file='self_connector',
base_url='https://%s' % DOMAIN,
books_url='https://%s/book' % DOMAIN,
covers_url='https://%s/images/covers' % DOMAIN,
search_url='https://%s/search?q=' % DOMAIN,
priority=1,
)
def test_create_job(self):
''' creates the import job entry and checks csv '''
import_job = goodreads_import.create_job(
self.user, self.csv, False, 'public')
self.assertEqual(import_job.user, self.user)
self.assertEqual(import_job.include_reviews, False)
self.assertEqual(import_job.privacy, 'public')
import_items = models.ImportItem.objects.filter(job=import_job).all()
self.assertEqual(len(import_items), 3)
self.assertEqual(import_items[0].index, 0)
self.assertEqual(import_items[0].data['Book Id'], '42036538')
self.assertEqual(import_items[1].index, 1)
self.assertEqual(import_items[1].data['Book Id'], '52691223')
self.assertEqual(import_items[2].index, 2)
self.assertEqual(import_items[2].data['Book Id'], '28694510')
def test_create_retry_job(self):
''' trying again with items that didn't import '''
import_job = goodreads_import.create_job(
self.user, self.csv, False, 'unlisted')
import_items = models.ImportItem.objects.filter(
job=import_job
).all()[:2]
retry = goodreads_import.create_retry_job(
self.user, import_job, import_items)
self.assertNotEqual(import_job, retry)
self.assertEqual(retry.user, self.user)
self.assertEqual(retry.include_reviews, False)
self.assertEqual(retry.privacy, 'unlisted')
retry_items = models.ImportItem.objects.filter(job=retry).all()
self.assertEqual(len(retry_items), 2)
self.assertEqual(retry_items[0].index, 0)
self.assertEqual(retry_items[0].data['Book Id'], '42036538')
self.assertEqual(retry_items[1].index, 1)
self.assertEqual(retry_items[1].data['Book Id'], '52691223')
def test_start_import(self):
''' begin loading books '''
import_job = goodreads_import.create_job(
self.user, self.csv, False, 'unlisted')
MockTask = namedtuple('Task', ('id'))
mock_task = MockTask(7)
with patch('bookwyrm.goodreads_import.import_data.delay') as start:
start.return_value = mock_task
goodreads_import.start_import(import_job)
import_job.refresh_from_db()
self.assertEqual(import_job.task_id, '7')
@responses.activate
def test_import_data(self):
''' resolve entry '''
import_job = goodreads_import.create_job(
self.user, self.csv, False, 'unlisted')
book = models.Edition.objects.create(title='Test Book')
with patch(
'bookwyrm.models.import_job.ImportItem.get_book_from_isbn'
) as resolve:
resolve.return_value = book
with patch('bookwyrm.outgoing.handle_imported_book'):
goodreads_import.import_data(import_job.id)
import_item = models.ImportItem.objects.get(job=import_job, index=0)
self.assertEqual(import_item.book.id, book.id)

View file

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

View file

@ -1,19 +1,24 @@
''' sending out activities '''
import csv
import json
import pathlib
from unittest.mock import patch
from django.http import JsonResponse
from django.test import TestCase
from django.test.client import RequestFactory
import responses
from bookwyrm import models, outgoing
from bookwyrm import forms, models, outgoing
from bookwyrm.settings import DOMAIN
# pylint: disable=too-many-public-methods
class Outgoing(TestCase):
''' sends out activities '''
def setUp(self):
''' we'll need some data '''
self.factory = RequestFactory()
with patch('bookwyrm.models.user.set_remote_server'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
@ -47,6 +52,67 @@ class Outgoing(TestCase):
)
def test_outbox(self):
''' returns user's statuses '''
request = self.factory.get('')
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
def test_outbox_bad_method(self):
''' can't POST to outbox '''
request = self.factory.post('')
result = outgoing.outbox(request, 'mouse')
self.assertEqual(result.status_code, 405)
def test_outbox_unknown_user(self):
''' should 404 for unknown and remote users '''
request = self.factory.post('')
result = outgoing.outbox(request, 'beepboop')
self.assertEqual(result.status_code, 405)
result = outgoing.outbox(request, 'rat')
self.assertEqual(result.status_code, 405)
def test_outbox_privacy(self):
''' don't show dms et cetera in outbox '''
models.Status.objects.create(
content='PRIVATE!!', user=self.local_user, privacy='direct')
models.Status.objects.create(
content='bffs ONLY', user=self.local_user, privacy='followers')
models.Status.objects.create(
content='unlisted status', user=self.local_user, privacy='unlisted')
models.Status.objects.create(
content='look at this', user=self.local_user, privacy='public')
request = self.factory.get('')
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 2)
def test_outbox_filter(self):
''' if we only care about reviews, only get reviews '''
models.Review.objects.create(
content='look at this', name='hi', rating=1,
book=self.book, user=self.local_user)
models.Status.objects.create(
content='look at this', user=self.local_user)
request = self.factory.get('', {'type': 'bleh'})
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 2)
request = self.factory.get('', {'type': 'Review'})
result = outgoing.outbox(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.content)
self.assertEqual(data['type'], 'OrderedCollection')
self.assertEqual(data['totalItems'], 1)
def test_handle_follow(self):
''' send a follow request '''
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
@ -192,3 +258,190 @@ class Outgoing(TestCase):
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unshelve(self.local_user, self.book, self.shelf)
self.assertEqual(self.shelf.books.count(), 0)
def test_handle_imported_book(self):
''' goodreads import added a book, this adds related connections '''
shelf = self.local_user.shelf_set.filter(identifier='read').first()
self.assertIsNone(shelf.books.first())
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
readthrough = models.ReadThrough.objects.get(user=self.local_user)
self.assertEqual(readthrough.book, self.book)
# I can't remember how to create dates and I don't want to look it up.
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_imported_book_already_shelved(self):
''' goodreads import added a book, this adds related connections '''
shelf = self.local_user.shelf_set.filter(identifier='to-read').first()
models.ShelfBook.objects.create(
shelf=shelf, added_by=self.local_user, book=self.book)
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
for index, entry in enumerate(list(csv.DictReader(csv_file))):
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=index, data=entry, book=self.book)
break
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, False, 'public')
shelf.refresh_from_db()
self.assertEqual(shelf.books.first(), self.book)
self.assertIsNone(
self.local_user.shelf_set.get(identifier='read').books.first())
readthrough = models.ReadThrough.objects.get(user=self.local_user)
self.assertEqual(readthrough.book, self.book)
self.assertEqual(readthrough.start_date.year, 2020)
self.assertEqual(readthrough.start_date.month, 10)
self.assertEqual(readthrough.start_date.day, 21)
self.assertEqual(readthrough.finish_date.year, 2020)
self.assertEqual(readthrough.finish_date.month, 10)
self.assertEqual(readthrough.finish_date.day, 25)
def test_handle_imported_book_review(self):
''' goodreads review import '''
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
entry = list(csv.DictReader(csv_file))[2]
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, True, 'unlisted')
review = models.Review.objects.get(book=self.book, user=self.local_user)
self.assertEqual(review.content, 'mixed feelings')
self.assertEqual(review.rating, 2)
self.assertEqual(review.published_date.year, 2019)
self.assertEqual(review.published_date.month, 7)
self.assertEqual(review.published_date.day, 8)
self.assertEqual(review.privacy, 'unlisted')
def test_handle_imported_book_reviews_disabled(self):
''' goodreads review import '''
import_job = models.ImportJob.objects.create(user=self.local_user)
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
csv_file = open(datafile, 'r')
entry = list(csv.DictReader(csv_file))[2]
import_item = models.ImportItem.objects.create(
job_id=import_job.id, index=0, data=entry, book=self.book)
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_imported_book(
self.local_user, import_item, False, 'unlisted')
self.assertFalse(models.Review.objects.filter(
book=self.book, user=self.local_user
).exists())
def test_handle_status(self):
''' create a status '''
form = forms.CommentForm({
'content': 'hi',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(self.local_user, form)
status = models.Comment.objects.get()
self.assertEqual(status.content, '<p>hi</p>')
self.assertEqual(status.user, self.local_user)
self.assertEqual(status.book, self.book)
def test_handle_status_reply(self):
''' create a status in reply to an existing status '''
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password', local=True)
parent = models.Status.objects.create(
content='parent status', user=self.local_user)
form = forms.ReplyForm({
'content': 'hi',
'user': user.id,
'reply_parent': parent.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(user, form)
status = models.Status.objects.get(user=user)
self.assertEqual(status.content, '<p>hi</p>')
self.assertEqual(status.user, user)
self.assertEqual(
models.Notification.objects.get().user, self.local_user)
def test_handle_status_mentions(self):
''' @mention a user in a post '''
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password', local=True)
form = forms.CommentForm({
'content': 'hi @rat',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(self.local_user, form)
status = models.Status.objects.get()
self.assertEqual(
status.content,
'<p>hi <a href="%s">@rat</a></p>' % user.remote_id)
self.assertEqual(list(status.mention_users.all()), [user])
self.assertEqual(models.Notification.objects.get().user, user)
def test_handle_status_reply_with_mentions(self):
''' reply to a post with an @mention'ed user '''
user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'password', local=True)
form = forms.CommentForm({
'content': 'hi @rat@example.com',
'user': self.local_user.id,
'book': self.book.id,
'privacy': 'public',
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(self.local_user, form)
status = models.Status.objects.get()
form = forms.ReplyForm({
'content': 'right',
'user': user,
'privacy': 'public',
'reply_parent': status.id
})
with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_status(user, form)
reply = models.Status.replies(status).first()
self.assertEqual(reply.content, '<p>right</p>')
self.assertEqual(reply.user, user)
self.assertTrue(self.remote_user in reply.mention_users.all())
self.assertTrue(self.local_user in reply.mention_users.all())

View file

@ -42,6 +42,9 @@ class ViewActions(TestCase):
content='Test status',
remote_id='https://example.com/status/1',
)
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Test Book', parent_work=self.work)
self.settings = models.SiteSettings.objects.create(id=1)
self.factory = RequestFactory()
@ -352,3 +355,43 @@ class ViewActions(TestCase):
author.refresh_from_db()
self.assertEqual(author.name, 'Test Author')
self.assertEqual(resp.template_name, 'edit_author.html')
def test_tag(self):
''' add a tag to a book '''
request = self.factory.post(
'', {
'name': 'A Tag!?',
'book': self.book.id,
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.tag(request)
tag = models.Tag.objects.get()
user_tag = models.UserTag.objects.get()
self.assertEqual(tag.name, 'A Tag!?')
self.assertEqual(tag.identifier, 'A+Tag%21%3F')
self.assertEqual(user_tag.user, self.local_user)
self.assertEqual(user_tag.book, self.book)
def test_untag(self):
''' remove a tag from a book '''
tag = models.Tag.objects.create(name='A Tag!?')
user_tag = models.UserTag.objects.create(
user=self.local_user, book=self.book, tag=tag)
request = self.factory.post(
'', {
'user': self.local_user.id,
'book': self.book.id,
'name': tag.name,
})
request.user = self.local_user
with patch('bookwyrm.broadcast.broadcast_task.delay'):
actions.untag(request)
self.assertTrue(models.Tag.objects.filter(name='A Tag!?').exists())
self.assertFalse(models.UserTag.objects.exists())

View file

@ -0,0 +1,567 @@
''' test for app action functionality '''
import json
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.connectors import abstract_connector
from bookwyrm.settings import DOMAIN, USER_AGENT
# pylint: disable=too-many-public-methods
class Views(TestCase):
''' every response to a get request, html or json '''
def setUp(self):
''' we need basic test data and mocks '''
self.factory = RequestFactory()
self.work = models.Work.objects.create(title='Test Work')
self.book = models.Edition.objects.create(
title='Test Book', parent_work=self.work)
models.Connector.objects.create(
identifier='self',
connector_file='self_connector',
local=True
)
self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'password', local=True)
with patch('bookwyrm.models.user.set_remote_server.delay'):
self.remote_user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
local=False,
remote_id='https://example.com/users/rat',
inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
def test_get_edition(self):
''' given an edition or a work, returns an edition '''
self.assertEqual(
views.get_edition(self.book.id), self.book)
self.assertEqual(
views.get_edition(self.work.id), self.book)
def test_get_user_from_username(self):
''' works for either localname or username '''
self.assertEqual(
views.get_user_from_username('mouse'), self.local_user)
self.assertEqual(
views.get_user_from_username('mouse@%s' % DOMAIN), self.local_user)
with self.assertRaises(models.User.DoesNotExist):
views.get_user_from_username('mojfse@example.com')
def test_is_api_request(self):
''' should it return html or json '''
request = self.factory.get('/path')
request.headers = {'Accept': 'application/json'}
self.assertTrue(views.is_api_request(request))
request = self.factory.get('/path.json')
request.headers = {'Accept': 'Praise'}
self.assertTrue(views.is_api_request(request))
request = self.factory.get('/path')
request.headers = {'Accept': 'Praise'}
self.assertFalse(views.is_api_request(request))
def test_home_tab(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.home_tab(request, 'local')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'feed.html')
self.assertEqual(result.status_code, 200)
def test_direct_messages_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.direct_messages_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'direct_messages.html')
self.assertEqual(result.status_code, 200)
def test_get_activity_feed(self):
''' loads statuses '''
rat = models.User.objects.create_user(
'rat', 'rat@rat.rat', 'password', local=True)
public_status = models.Comment.objects.create(
content='public status', book=self.book, user=self.local_user)
direct_status = models.Status.objects.create(
content='direct', user=self.local_user, privacy='direct')
rat_public = models.Status.objects.create(
content='blah blah', user=rat)
rat_unlisted = models.Status.objects.create(
content='blah blah', user=rat, privacy='unlisted')
remote_status = models.Status.objects.create(
content='blah blah', user=self.remote_user)
followers_status = models.Status.objects.create(
content='blah', user=rat, privacy='followers')
rat_mention = models.Status.objects.create(
content='blah blah blah', user=rat, privacy='followers')
rat_mention.mention_users.set([self.local_user])
statuses = views.get_activity_feed(self.local_user, 'home')
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_mention)
statuses = views.get_activity_feed(
self.local_user, 'home', model=models.Comment)
self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], public_status)
statuses = views.get_activity_feed(self.local_user, 'local')
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_public)
statuses = views.get_activity_feed(self.local_user, 'direct')
self.assertEqual(len(statuses), 1)
self.assertEqual(statuses[0], direct_status)
statuses = views.get_activity_feed(self.local_user, 'federated')
self.assertEqual(len(statuses), 3)
self.assertEqual(statuses[2], public_status)
self.assertEqual(statuses[1], rat_public)
self.assertEqual(statuses[0], remote_status)
statuses = views.get_activity_feed(self.local_user, 'friends')
self.assertEqual(len(statuses), 2)
self.assertEqual(statuses[1], public_status)
self.assertEqual(statuses[0], rat_mention)
rat.followers.add(self.local_user)
statuses = views.get_activity_feed(self.local_user, 'friends')
self.assertEqual(len(statuses), 5)
self.assertEqual(statuses[4], public_status)
self.assertEqual(statuses[3], rat_public)
self.assertEqual(statuses[2], rat_unlisted)
self.assertEqual(statuses[1], followers_status)
self.assertEqual(statuses[0], rat_mention)
def test_search_json_response(self):
''' searches local data only and returns book data in json format '''
# we need a connector for this, sorry
request = self.factory.get('', {'q': 'Test Book'})
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
response = views.search(request)
self.assertIsInstance(response, JsonResponse)
data = json.loads(response.content)
self.assertEqual(len(data), 1)
self.assertEqual(data[0]['title'], 'Test Book')
self.assertEqual(
data[0]['key'], 'https://%s/book/%d' % (DOMAIN, self.book.id))
def test_search_html_response(self):
''' searches remote connectors '''
class TestConnector(abstract_connector.AbstractMinimalConnector):
''' nothing added here '''
def format_search_result(self, search_result):
pass
def get_or_create_book(self, remote_id):
pass
def parse_search_data(self, data):
pass
models.Connector.objects.create(
identifier='example.com',
connector_file='openlibrary',
base_url='https://example.com',
books_url='https://example.com/books',
covers_url='https://example.com/covers',
search_url='https://example.com/search?q=',
)
connector = TestConnector('example.com')
search_result = abstract_connector.SearchResult(
key='http://www.example.com/book/1',
title='Gideon the Ninth',
author='Tamsyn Muir',
year='2019',
connector=connector
)
request = self.factory.get('', {'q': 'Test Book'})
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
with patch(
'bookwyrm.connectors.connector_manager.search') as manager:
manager.return_value = [search_result]
response = views.search(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
self.assertEqual(
response.context_data['book_results'][0].title, 'Gideon the Ninth')
def test_search_html_response_users(self):
''' searches remote connectors '''
request = self.factory.get('', {'q': 'mouse'})
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
with patch('bookwyrm.connectors.connector_manager.search'):
response = views.search(request)
self.assertIsInstance(response, TemplateResponse)
self.assertEqual(response.template_name, 'search_results.html')
self.assertEqual(
response.context_data['user_results'][0], self.local_user)
def test_import_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.import_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import.html')
self.assertEqual(result.status_code, 200)
def test_import_status(self):
''' there are so many views, this just makes sure it LOADS '''
import_job = models.ImportJob.objects.create(user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.tasks.app.AsyncResult') as async_result:
async_result.return_value = []
result = views.import_status(request, import_job.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'import_status.html')
self.assertEqual(result.status_code, 200)
def test_login_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = AnonymousUser
result = views.login_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'login.html')
self.assertEqual(result.status_code, 200)
request.user = self.local_user
result = views.login_page(request)
self.assertEqual(result.url, '/')
self.assertEqual(result.status_code, 302)
def test_about_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.about_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'about.html')
self.assertEqual(result.status_code, 200)
def test_password_reset_request(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.password_reset_request(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'password_reset_request.html')
self.assertEqual(result.status_code, 200)
def test_password_reset(self):
''' there are so many views, this just makes sure it LOADS '''
code = models.PasswordReset.objects.create(user=self.local_user)
request = self.factory.get('')
request.user = AnonymousUser
result = views.password_reset(request, code.code)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'password_reset.html')
self.assertEqual(result.status_code, 200)
def test_invite_page(self):
''' there are so many views, this just makes sure it LOADS '''
models.SiteInvite.objects.create(code='hi', user=self.local_user)
request = self.factory.get('')
request.user = AnonymousUser
# why?? this is annoying.
request.user.is_authenticated = False
with patch('bookwyrm.models.site.SiteInvite.valid') as invite:
invite.return_value = True
result = views.invite_page(request, 'hi')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'invite.html')
self.assertEqual(result.status_code, 200)
def test_manage_invites(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = views.manage_invites(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'manage_invites.html')
self.assertEqual(result.status_code, 200)
def test_notifications_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.notifications_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'notifications.html')
self.assertEqual(result.status_code, 200)
def test_user_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.user_page(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'user.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.user_page(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_followers_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.followers_page(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'followers.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.followers_page(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_following_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.following_page(request, 'mouse')
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'following.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.following_page(request, 'mouse')
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_status_page(self):
''' there are so many views, this just makes sure it LOADS '''
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.status_page(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'status.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.status_page(request, 'mouse', status.id)
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_replies_page(self):
''' there are so many views, this just makes sure it LOADS '''
status = models.Status.objects.create(
content='hi', user=self.local_user)
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.replies_page(request, 'mouse', status.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'status.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.replies_page(request, 'mouse', status.id)
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_edit_profile_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
result = views.edit_profile_page(request)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_user.html')
self.assertEqual(result.status_code, 200)
def test_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.book_page(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'book.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.book_page(request, self.book.id)
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_edit_book_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = views.edit_book_page(request, self.book.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_book.html')
self.assertEqual(result.status_code, 200)
def test_edit_author_page(self):
''' there are so many views, this just makes sure it LOADS '''
author = models.Author.objects.create(name='Test Author')
request = self.factory.get('')
request.user = self.local_user
request.user.is_superuser = True
result = views.edit_author_page(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'edit_author.html')
self.assertEqual(result.status_code, 200)
def test_editions_page(self):
''' there are so many views, this just makes sure it LOADS '''
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.editions_page(request, self.work.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'editions.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.editions_page(request, self.work.id)
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_author_page(self):
''' there are so many views, this just makes sure it LOADS '''
author = models.Author.objects.create(name='Jessica')
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.author_page(request, author.id)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'author.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.author_page(request, author.id)
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_tag_page(self):
''' there are so many views, this just makes sure it LOADS '''
tag = models.Tag.objects.create(name='hi there')
models.UserTag.objects.create(
tag=tag, user=self.local_user, book=self.book)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.tag_page(request, tag.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'tag.html')
self.assertEqual(result.status_code, 200)
request = self.factory.get('')
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.tag_page(request, tag.identifier)
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_shelf_page(self):
''' there are so many views, this just makes sure it LOADS '''
shelf = self.local_user.shelf_set.first()
request = self.factory.get('')
request.user = self.local_user
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = False
result = views.shelf_page(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, TemplateResponse)
self.assertEqual(result.template_name, 'shelf.html')
self.assertEqual(result.status_code, 200)
with patch('bookwyrm.views.is_api_request') as is_api:
is_api.return_value = True
result = views.shelf_page(
request, self.local_user.username, shelf.identifier)
self.assertIsInstance(result, JsonResponse)
self.assertEqual(result.status_code, 200)
def test_is_bookwyrm_request(self):
''' tests the function that checks if a request came from a bookwyrm instance '''
request = self.factory.get('', {'q': 'Test Book'})
self.assertFalse(views.is_bookworm_request(request))
request = self.factory.get('', {'q': 'Test Book'},
HTTP_USER_AGENT="http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)")
self.assertFalse(views.is_bookworm_request(request))
request = self.factory.get('', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT)
self.assertTrue(views.is_bookworm_request(request))

View file

@ -5,11 +5,10 @@ from django.urls import path, re_path
from bookwyrm import incoming, outgoing, views, settings, wellknown
from bookwyrm import view_actions as actions
from bookwyrm.utils import regex
username_regex = r'(?P<username>[\w\-_\.]+@[\w\-\_\.]+)'
localname_regex = r'(?P<username>[\w\-_\.]+)'
user_path = r'^user/%s' % username_regex
local_user_path = r'^user/%s' % localname_regex
user_path = r'^user/(?P<username>%s)' % regex.username
local_user_path = r'^user/(?P<username>%s)' % regex.localname
status_types = [
'status',
@ -20,7 +19,7 @@ status_types = [
'generatednote'
]
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
(local_user_path, '|'.join(status_types))
(user_path, '|'.join(status_types))
book_path = r'^book/(?P<book_id>\d+)'
@ -53,6 +52,7 @@ urlpatterns = [
path('', views.home),
re_path(r'^(?P<tab>home|local|federated)/?$', views.home_tab),
re_path(r'^discover/?$', views.discover_page),
re_path(r'^notifications/?$', views.notifications_page),
re_path(r'^direct-messages/?$', views.direct_messages_page),
re_path(r'^import/?$', views.import_page),

View file

@ -1,5 +1,10 @@
''' defining regexes for regularly used concepts '''
domain = r'[a-z-A-Z0-9_\-]+\.[a-z]+'
username = r'@[a-zA-Z_\-\.0-9]+(@%s)?' % domain
full_username = r'@?[a-zA-Z_\-\.0-9]+@%s' % domain
localname = r'@?[a-zA-Z_\-\.0-9]+'
strict_localname = r'@[a-zA-Z_\-\.0-9]+'
username = r'%s(@%s)?' % (localname, domain)
strict_username = r'%s(@%s)?' % (strict_localname, domain)
full_username = r'%s@%s' % (localname, domain)
# should match (BookWyrm/1.0.0; or (BookWyrm/99.1.2;
bookwyrm_user_agent = r'\(BookWyrm/[0-9]+\.[0-9]+\.[0-9]+;'

View file

@ -17,11 +17,12 @@ 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, forms, models, outgoing, goodreads_import
from bookwyrm import forms, models, outgoing, goodreads_import
from bookwyrm.connectors import connector_manager
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
from bookwyrm.views import get_user_from_username, get_edition
@require_POST
@ -211,10 +212,8 @@ def edit_profile(request):
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)
connector = connector_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)
@ -372,7 +371,7 @@ def delete_shelf(request, shelf_id):
@require_POST
def shelve(request):
''' put a on a user's shelf '''
book = books_manager.get_edition(request.POST['book'])
book = get_edition(request.POST['book'])
desired_shelf = models.Shelf.objects.filter(
identifier=request.POST['shelf'],
@ -418,7 +417,7 @@ def unshelve(request):
@require_POST
def start_reading(request, book_id):
''' begin reading a book '''
book = books_manager.get_edition(book_id)
book = get_edition(book_id)
shelf = models.Shelf.objects.filter(
identifier='reading',
user=request.user
@ -454,7 +453,7 @@ def start_reading(request, book_id):
@require_POST
def finish_reading(request, book_id):
''' a user completed a book, yay '''
book = books_manager.get_edition(book_id)
book = get_edition(book_id)
shelf = models.Shelf.objects.filter(
identifier='read',
user=request.user
@ -578,14 +577,14 @@ def tag(request):
tag_obj, created = models.Tag.objects.get_or_create(
name=name,
)
user_tag = models.UserTag.objects.get_or_create(
user_tag, _ = models.UserTag.objects.get_or_create(
user=request.user,
book=book,
tag=tag_obj,
)
if created:
outgoing.handle_tag(request.user, user_tag)
broadcast(request.user, user_tag.to_add_activity(request.user))
return redirect('/book/%s' % book_id)
@ -594,9 +593,16 @@ def tag(request):
def untag(request):
''' untag a book '''
name = request.POST.get('name')
tag_obj = get_object_or_404(models.Tag, name=name)
book_id = request.POST.get('book')
book = get_object_or_404(models.Edition, id=book_id)
outgoing.handle_untag(request.user, book_id, name)
user_tag = get_object_or_404(
models.UserTag, tag=tag_obj, book=book, user=request.user)
tag_activity = user_tag.to_remove_activity(request.user)
user_tag.delete()
broadcast(request.user, tag_activity)
return redirect('/book/%s' % book_id)

View file

@ -4,7 +4,8 @@ import re
from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import TrigramSimilarity
from django.core.paginator import Paginator
from django.db.models import Avg, Q
from django.db.models import Avg, Q, Max
from django.db.models.functions import Greatest
from django.http import HttpResponseNotFound, JsonResponse
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect
@ -13,21 +14,28 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from bookwyrm import outgoing
from bookwyrm.activitypub import ActivityEncoder
from bookwyrm import forms, models, books_manager
from bookwyrm import goodreads_import
from bookwyrm import forms, models
from bookwyrm.activitypub import ActivitypubResponse
from bookwyrm.connectors import connector_manager
from bookwyrm.settings import PAGE_LENGTH
from bookwyrm.tasks import app
from bookwyrm.utils import regex
def get_edition(book_id):
''' look up a book in the db and return an edition '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if isinstance(book, models.Work):
book = book.get_default_edition()
return book
def get_user_from_username(username):
''' helper function to resolve a localname or a username to a user '''
# raises DoesNotExist if user is now found
try:
user = models.User.objects.get(localname=username)
return models.User.objects.get(localname=username)
except models.User.DoesNotExist:
user = models.User.objects.get(username=username)
return user
return models.User.objects.get(username=username)
def is_api_request(request):
@ -35,22 +43,33 @@ def is_api_request(request):
return 'json' in request.headers.get('Accept') or \
request.path[-5:] == '.json'
def is_bookworm_request(request):
''' check if the request is coming from another bookworm instance '''
user_agent = request.headers.get('User-Agent')
if user_agent is None or \
re.search(regex.bookwyrm_user_agent, user_agent) is None:
return False
return True
def server_error_page(request):
''' 500 errors '''
return TemplateResponse(request, 'error.html', {'title': 'Oops!'})
return TemplateResponse(
request, 'error.html', {'title': 'Oops!'}, status=500)
def not_found_page(request, _):
''' 404s '''
return TemplateResponse(request, 'notfound.html', {'title': 'Not found'})
return TemplateResponse(
request, 'notfound.html', {'title': 'Not found'}, status=404)
@login_required
@require_GET
def home(request):
''' this is the same as the feed on the home tab '''
return home_tab(request, 'home')
if request.user.is_authenticated:
return home_tab(request, 'home')
return discover_page(request)
@login_required
@ -113,6 +132,36 @@ def get_suggested_books(user, max_books=5):
return suggested_books
@require_GET
def discover_page(request):
''' tiled book activity page '''
books = models.Edition.objects.filter(
review__published_date__isnull=False,
review__user__local=True,
review__privacy__in=['public', 'unlisted'],
).exclude(
cover__exact=''
).annotate(
Max('review__published_date')
).order_by('-review__published_date__max')[:6]
ratings = {}
for book in books:
reviews = models.Review.objects.filter(
book__in=book.parent_work.editions.all()
)
reviews = get_activity_feed(
request.user, 'federated', model=reviews)
ratings[book.id] = reviews.aggregate(Avg('rating'))['rating__avg']
data = {
'title': 'Discover',
'register_form': forms.RegisterForm(),
'books': list(set(books)),
'ratings': ratings
}
return TemplateResponse(request, 'discover.html', data)
@login_required
@require_GET
def direct_messages_page(request, page=1):
@ -164,7 +213,7 @@ def get_activity_feed(user, filter_level, model=models.Status):
return activities.filter(
Q(user=user) | Q(mention_users=user),
privacy='direct'
)
).distinct()
# never show DMs in the regular feed
activities = activities.filter(~Q(privacy='direct'))
@ -179,7 +228,7 @@ def get_activity_feed(user, filter_level, model=models.Status):
Q(user__in=following, privacy__in=[
'public', 'unlisted', 'followers'
]) | Q(mention_users=user) | Q(user=user)
)
).distinct()
elif filter_level == 'self':
activities = activities.filter(user=user, privacy='public')
elif filter_level == 'local':
@ -209,8 +258,8 @@ def search(request):
if is_api_request(request):
# only return local book results via json so we don't cause a cascade
book_results = books_manager.local_search(query)
return JsonResponse([r.__dict__ for r in book_results], safe=False)
book_results = connector_manager.local_search(query)
return JsonResponse([r.json() for r in book_results], safe=False)
# use webfinger for mastodon style account@domain.com username
if re.match(regex.full_username, query):
@ -218,12 +267,15 @@ def search(request):
# do a local user search
user_results = models.User.objects.annotate(
similarity=TrigramSimilarity('username', query),
similarity=Greatest(
TrigramSimilarity('username', query),
TrigramSimilarity('localname', query),
)
).filter(
similarity__gt=0.5,
).order_by('-similarity')[:10]
book_results = books_manager.search(query)
book_results = connector_manager.search(query)
data = {
'title': 'Search Results',
'book_results': book_results,
@ -242,7 +294,6 @@ def import_page(request):
'import_form': forms.ImportForm(),
'jobs': models.ImportJob.
objects.filter(user=request.user).order_by('-created_date'),
'limit': goodreads_import.MAX_ENTRIES,
})
@ -378,7 +429,7 @@ def user_page(request, username):
if is_api_request(request):
# we have a json request
return JsonResponse(user.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(user.to_activity())
# otherwise we're at a UI view
try:
@ -403,7 +454,7 @@ def user_page(request, username):
continue
shelf_preview.append({
'name': user_shelf.name,
'remote_id': user_shelf.remote_id,
'local_path': user_shelf.local_path,
'books': user_shelf.books.all()[:3],
'size': user_shelf.books.count(),
})
@ -446,7 +497,7 @@ def followers_page(request, username):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(user.to_followers_activity(**request.GET))
return ActivitypubResponse(user.to_followers_activity(**request.GET))
data = {
'title': '%s: followers' % user.name,
@ -467,7 +518,7 @@ def following_page(request, username):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(user.to_following_activity(**request.GET))
return ActivitypubResponse(user.to_following_activity(**request.GET))
data = {
'title': '%s: following' % user.name,
@ -497,7 +548,8 @@ def status_page(request, username, status_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(status.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(
status.to_activity(pure=not is_bookworm_request(request)))
data = {
'title': 'Status by %s' % user.username,
@ -530,10 +582,7 @@ def replies_page(request, username, status_id):
if status.user.localname != username:
return HttpResponseNotFound()
return JsonResponse(
status.to_replies(**request.GET),
encoder=ActivityEncoder
)
return ActivitypubResponse(status.to_replies(**request.GET))
@login_required
@ -565,7 +614,7 @@ def book_page(request, book_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(book.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(book.to_activity())
if isinstance(book, models.Work):
book = book.get_default_edition()
@ -646,7 +695,7 @@ def book_page(request, book_id):
@require_GET
def edit_book_page(request, book_id):
''' info about a book '''
book = books_manager.get_edition(book_id)
book = get_edition(book_id)
if not book.description:
book.description = book.parent_work.description
data = {
@ -677,10 +726,7 @@ def editions_page(request, book_id):
work = get_object_or_404(models.Work, id=book_id)
if is_api_request(request):
return JsonResponse(
work.to_edition_list(**request.GET),
encoder=ActivityEncoder
)
return ActivitypubResponse(work.to_edition_list(**request.GET))
data = {
'title': 'Editions of %s' % work.title,
@ -696,7 +742,7 @@ def author_page(request, author_id):
author = get_object_or_404(models.Author, id=author_id)
if is_api_request(request):
return JsonResponse(author.to_activity(), encoder=ActivityEncoder)
return ActivitypubResponse(author.to_activity())
books = models.Work.objects.filter(
Q(authors=author) | Q(editions__authors=author)).distinct()
@ -716,8 +762,7 @@ def tag_page(request, tag_id):
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(
tag_obj.to_activity(**request.GET), encoder=ActivityEncoder)
return ActivitypubResponse(tag_obj.to_activity(**request.GET))
books = models.Edition.objects.filter(
usertag__tag__identifier=tag_id
@ -768,7 +813,11 @@ def shelf_page(request, username, shelf_identifier):
if is_api_request(request):
return JsonResponse(shelf.to_activity(**request.GET))
return ActivitypubResponse(shelf.to_activity(**request.GET))
books = models.ShelfBook.objects.filter(
added_by=user, shelf=shelf
).order_by('-updated_date').all()
data = {
'title': '%s\'s %s shelf' % (user.display_name, shelf.name),
@ -776,6 +825,7 @@ def shelf_page(request, username, shelf_identifier):
'is_self': is_self,
'shelves': shelves.all(),
'shelf': shelf,
'books': [b.book for b in books],
}
return TemplateResponse(request, 'shelf.html', data)

View file

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

View file

@ -20,8 +20,9 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
app.autodiscover_tasks(['bookwyrm'], related_name='activitypub.base_activity')
app.autodiscover_tasks(['bookwyrm'], related_name='books_manager')
app.autodiscover_tasks(['bookwyrm'], related_name='broadcast')
app.autodiscover_tasks(
['bookwyrm'], related_name='connectors.abstract_connector')
app.autodiscover_tasks(['bookwyrm'], related_name='emailing')
app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import')
app.autodiscover_tasks(['bookwyrm'], related_name='incoming')