Merge pull request #372 from mouse-reeve/follow-remote-ids

Resolve remote ids automatically rather than handling individual cases
This commit is contained in:
Mouse Reeve 2020-12-12 15:59:36 -08:00 committed by GitHub
commit b8d9e2eb24
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
68 changed files with 2731 additions and 1346 deletions

View file

@ -2,20 +2,19 @@
import inspect import inspect
import sys import sys
from .base_activity import ActivityEncoder, PublicKey, Signature from .base_activity import ActivityEncoder, Signature
from .base_activity import Link, Mention from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError from .base_activity import ActivitySerializerError, resolve_remote_id
from .base_activity import tag_formatter
from .image import Image from .image import Image
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone from .note import Tombstone
from .interaction import Boost, Like from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person from .person import Person, PublicKey
from .book import Edition, Work, Author from .book import Edition, Work, Author
from .verbs import Create, Delete, Undo, Update from .verbs import Create, Delete, Undo, Update
from .verbs import Follow, Accept, Reject from .verbs import Follow, Accept, Reject
from .verbs import Add, Remove from .verbs import Add, AddBook, Remove
# this creates a list of all the Activity types that we can serialize, # this creates a list of all the Activity types that we can serialize,
# so when an Activity comes in from outside, we can check if it's known # so when an Activity comes in from outside, we can check if it's known

View file

@ -1,18 +1,14 @@
''' basics for an activitypub serializer ''' ''' basics for an activitypub serializer '''
from dataclasses import dataclass, fields, MISSING from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder from json import JSONEncoder
from uuid import uuid4
from django.core.files.base import ContentFile from django.apps import apps
from django.db import transaction from django.db import transaction
from django.db.models.fields.related_descriptors \
import ForwardManyToOneDescriptor, ManyToManyDescriptor, \
ReverseManyToOneDescriptor
from django.db.models.fields.files import ImageFileDescriptor from django.db.models.fields.files import ImageFileDescriptor
import requests from django.db.models.fields.related_descriptors import ManyToManyDescriptor
from bookwyrm import books_manager, models
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
class ActivitySerializerError(ValueError): class ActivitySerializerError(ValueError):
''' routine problems serializing activitypub json ''' ''' routine problems serializing activitypub json '''
@ -25,26 +21,19 @@ class ActivityEncoder(JSONEncoder):
@dataclass @dataclass
class Link(): class Link:
''' for tagging a book in a status ''' ''' for tagging a book in a status '''
href: str href: str
name: str name: str
type: str = 'Link' type: str = 'Link'
@dataclass @dataclass
class Mention(Link): class Mention(Link):
''' a subtype of Link for mentioning an actor ''' ''' a subtype of Link for mentioning an actor '''
type: str = 'Mention' type: str = 'Mention'
@dataclass
class PublicKey:
''' public key block '''
id: str
owner: str
publicKeyPem: str
@dataclass @dataclass
class Signature: class Signature:
''' public key block ''' ''' public key block '''
@ -76,88 +65,88 @@ class ActivityObject:
setattr(self, field.name, value) setattr(self, field.name, value)
def to_model(self, model, instance=None): @transaction.atomic
def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance ''' ''' convert from an activity to a model instance '''
if not isinstance(self, model.activity_serializer): if not isinstance(self, model.activity_serializer):
raise ActivitySerializerError('Wrong activity type for model') raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
(self.__class__,
model.__name__,
model.activity_serializer)
)
# check for an existing instance, if we're not updating a known obj # check for an existing instance, if we're not updating a known obj
if not instance: if not instance:
try: instance = model.find_existing(self.serialize()) or model()
return model.objects.get(remote_id=self.id)
except model.DoesNotExist:
pass
model_fields = [m.name for m in model._meta.get_fields()]
mapped_fields = {}
many_to_many_fields = {} many_to_many_fields = {}
one_to_many_fields = {}
image_fields = {} image_fields = {}
for field in model._meta.get_fields():
for mapping in model.activity_mappings: # check if it's an activitypub field
if mapping.model_key not in model_fields: if not hasattr(field, 'field_to_activity'):
continue
# call the formatter associated with the model field class
value = field.field_from_activity(
getattr(self, field.get_activitypub_field())
)
if value is None or value is MISSING:
continue continue
# value is None if there's a default that isn't supplied
# in the activity but is supplied in the formatter
value = None
if mapping.activity_key:
value = getattr(self, mapping.activity_key)
model_field = getattr(model, mapping.model_key)
formatted_value = mapping.model_formatter(value) model_field = getattr(model, field.name)
if isinstance(model_field, ForwardManyToOneDescriptor) and \
formatted_value: if isinstance(model_field, ManyToManyDescriptor):
# foreign key remote id reolver (work on Edition, for example) # status mentions book/users for example, stash this for later
fk_model = model_field.field.related_model many_to_many_fields[field.name] = value
reference = resolve_foreign_key(fk_model, formatted_value)
mapped_fields[mapping.model_key] = reference
elif isinstance(model_field, ManyToManyDescriptor):
# status mentions book/users
many_to_many_fields[mapping.model_key] = formatted_value
elif isinstance(model_field, ReverseManyToOneDescriptor):
# attachments on Status, for example
one_to_many_fields[mapping.model_key] = formatted_value
elif isinstance(model_field, ImageFileDescriptor): elif isinstance(model_field, ImageFileDescriptor):
# image fields need custom handling # image fields need custom handling
image_fields[mapping.model_key] = formatted_value image_fields[field.name] = value
else: else:
mapped_fields[mapping.model_key] = formatted_value # just a good old fashioned model.field = value
setattr(instance, field.name, value)
with transaction.atomic(): # if this isn't here, it messes up saving users. who even knows.
if instance: for (model_key, value) in image_fields.items():
# updating an existing model isntance getattr(instance, model_key).save(*value, save=save)
for k, v in mapped_fields.items():
setattr(instance, k, v)
instance.save()
else:
# creating a new model instance
instance = model.objects.create(**mapped_fields)
# add images if not save:
for (model_key, value) in image_fields.items(): # we can't set many to many and reverse fields on an unsaved object
formatted_value = image_formatter(value) return instance
if not formatted_value:
continue
getattr(instance, model_key).save(*formatted_value, save=True)
for (model_key, values) in many_to_many_fields.items(): instance.save()
# mention books, mention users
getattr(instance, model_key).set(values)
# add one to many fields # add many to many fields, which have to be set post-save
for (model_key, values) in one_to_many_fields.items(): for (model_key, values) in many_to_many_fields.items():
if values == MISSING: # mention books/users, for example
continue getattr(instance, model_key).set(values)
model_field = getattr(instance, model_key)
model = model_field.model
for item in values:
item = model.activity_serializer(**item)
field_name = instance.__class__.__name__.lower()
with transaction.atomic():
item = item.to_model(model)
setattr(item, field_name, instance)
item.save()
if not save or not hasattr(model, 'deserialize_reverse_fields'):
return instance
# reversed relationships in the models
for (model_field_name, activity_field_name) in \
model.deserialize_reverse_fields:
# attachments on Status, for example
values = getattr(self, activity_field_name)
if values is None or values is MISSING:
continue
try:
# this is for one to many
related_model = getattr(model, model_field_name).field.model
except AttributeError:
# it's a one to one or foreign key
related_model = getattr(model, model_field_name)\
.related.related_model
values = [values]
for item in values:
set_related_field.delay(
related_model.__name__,
instance.__class__.__name__,
instance.__class__.__name__.lower(),
instance.remote_id,
item
)
return instance return instance
@ -168,66 +157,57 @@ class ActivityObject:
return data return data
def resolve_foreign_key(model, remote_id): @app.task
''' look up the remote_id on an activity json field ''' @transaction.atomic
if model in [models.Edition, models.Work, models.Book]: def set_related_field(
return books_manager.get_or_create_book(remote_id) model_name, origin_model_name,
related_field_name, related_remote_id, data):
''' load reverse related fields (editions, attachments) without blocking '''
model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True)
origin_model = apps.get_model(
'bookwyrm.%s' % origin_model_name,
require_ready=True
)
result = model.objects if isinstance(data, str):
if hasattr(model.objects, 'select_subclasses'): item = resolve_remote_id(model, data, save=False)
result = result.select_subclasses()
result = result.filter(
remote_id=remote_id
).first()
if not result:
raise ActivitySerializerError(
'Could not resolve remote_id in %s model: %s' % \
(model.__name__, remote_id))
return result
def tag_formatter(tags, tag_type):
''' helper function to extract foreign keys from tag activity json '''
if not isinstance(tags, list):
return []
items = []
types = {
'Book': models.Book,
'Mention': models.User,
}
for tag in [t for t in tags if t.get('type') == tag_type]:
if not tag_type in types:
continue
remote_id = tag.get('href')
try:
item = resolve_foreign_key(types[tag_type], remote_id)
except ActivitySerializerError:
continue
items.append(item)
return items
def image_formatter(image_slug):
''' helper function to load images and format them for a model '''
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
if isinstance(image_slug, dict):
url = image_slug.get('url')
elif isinstance(image_slug, str):
url = image_slug
else: else:
return None # look for a match based on all the available data
if not url: item = model.find_existing(data)
return None if not item:
try: # create a new model instance
response = requests.get(url) item = model.activity_serializer(**data)
except ConnectionError: item = item.to_model(model, save=False)
return None # this must exist because it's the object that triggered this function
if not response.ok: instance = origin_model.find_existing_by_remote_id(related_remote_id)
return None if not instance:
raise ValueError('Invalid related remote id: %s' % related_remote_id)
image_name = str(uuid4()) + '.' + url.split('.')[-1] # edition.parent_work = instance, for example
image_content = ContentFile(response.content) setattr(item, related_field_name, instance)
return [image_name, image_content] item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True):
''' take a remote_id and return an instance, creating if necessary '''
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
# load the data and create the object
try:
data = get_data(remote_id)
except (ConnectorException, ConnectionError):
raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \
(model.__name__, remote_id))
# check for existing items with shared unique identifiers
if not result:
result = model.find_existing(data)
if result and not refresh:
return result
item = model.activity_serializer(**data)
# if we're refreshing, "result" will be set and we'll update it
return item.to_model(model, instance=result, save=save)

View file

@ -12,13 +12,13 @@ class Book(ActivityObject):
sortTitle: str = '' sortTitle: str = ''
subtitle: str = '' subtitle: str = ''
description: str = '' description: str = ''
languages: List[str] languages: List[str] = field(default_factory=lambda: [])
series: str = '' series: str = ''
seriesNumber: str = '' seriesNumber: str = ''
subjects: List[str] subjects: List[str] = field(default_factory=lambda: [])
subjectPlaces: List[str] subjectPlaces: List[str] = field(default_factory=lambda: [])
authors: List[str] authors: List[str] = field(default_factory=lambda: [])
firstPublishedDate: str = '' firstPublishedDate: str = ''
publishedDate: str = '' publishedDate: str = ''
@ -33,22 +33,23 @@ class Book(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Edition(Book): class Edition(Book):
''' Edition instance of a book object ''' ''' Edition instance of a book object '''
isbn10: str
isbn13: str
oclcNumber: str
asin: str
pages: str
physicalFormat: str
publishers: List[str]
work: str work: str
isbn10: str = ''
isbn13: str = ''
oclcNumber: str = ''
asin: str = ''
pages: str = ''
physicalFormat: str = ''
publishers: List[str] = field(default_factory=lambda: [])
type: str = 'Edition' type: str = 'Edition'
@dataclass(init=False) @dataclass(init=False)
class Work(Book): class Work(Book):
''' work instance of a book object ''' ''' work instance of a book object '''
lccn: str lccn: str = ''
defaultEdition: str = ''
editions: List[str] editions: List[str]
type: str = 'Work' type: str = 'Work'

View file

@ -8,7 +8,6 @@ from .image import Image
@dataclass(init=False) @dataclass(init=False)
class Tombstone(ActivityObject): class Tombstone(ActivityObject):
''' the placeholder for a deleted status ''' ''' the placeholder for a deleted status '''
url: str
published: str published: str
deleted: str deleted: str
type: str = 'Tombstone' type: str = 'Tombstone'
@ -17,14 +16,13 @@ class Tombstone(ActivityObject):
@dataclass(init=False) @dataclass(init=False)
class Note(ActivityObject): class Note(ActivityObject):
''' Note activity ''' ''' Note activity '''
url: str
inReplyTo: str
published: str published: str
attributedTo: str attributedTo: str
to: List[str]
cc: List[str]
content: str content: str
replies: Dict to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})
inReplyTo: str = ''
tag: List[Link] = field(default_factory=lambda: []) tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: []) attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False sensitive: bool = False

View file

@ -12,6 +12,7 @@ class OrderedCollection(ActivityObject):
first: str first: str
last: str = '' last: str = ''
name: str = '' name: str = ''
owner: str = ''
type: str = 'OrderedCollection' type: str = 'OrderedCollection'

View file

@ -2,9 +2,18 @@
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Dict from typing import Dict
from .base_activity import ActivityObject, PublicKey from .base_activity import ActivityObject
from .image import Image from .image import Image
@dataclass(init=False)
class PublicKey(ActivityObject):
''' public key block '''
owner: str
publicKeyPem: str
type: str = 'PublicKey'
@dataclass(init=False) @dataclass(init=False)
class Person(ActivityObject): class Person(ActivityObject):
''' actor activitypub json ''' ''' actor activitypub json '''

View file

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

View file

@ -16,23 +16,6 @@ def get_edition(book_id):
return book return book
def get_or_create_book(remote_id):
''' pull up a book record by whatever means possible '''
book = models.Book.objects.select_subclasses().filter(
remote_id=remote_id
).first()
if book:
return book
connector = get_or_create_connector(remote_id)
# raises ConnectorException
book = connector.get_or_create_book(remote_id)
if book:
load_more_data.delay(book.id)
return book
def get_or_create_connector(remote_id): def get_or_create_connector(remote_id):
''' get the connector related to the author's server ''' ''' get the connector related to the author's server '''
url = urlparse(remote_id) url = urlparse(remote_id)
@ -102,12 +85,6 @@ def first_search_result(query, min_confidence=0.1):
return None return None
def update_book(book, data=None):
''' re-sync with the original data source '''
connector = load_connector(book.connector)
connector.update_book(book, data=data)
def get_connectors(): def get_connectors():
''' load all connectors ''' ''' load all connectors '''
for info in models.Connector.objects.order_by('priority').all(): for info in models.Connector.objects.order_by('priority').all():

View file

@ -65,7 +65,7 @@ def sign_and_send(sender, data, destination):
''' crpyto whatever and http junk ''' ''' crpyto whatever and http junk '''
now = http_date() now = http_date()
if not sender.private_key: if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened. # this shouldn't happen. it would be bad if it happened.
raise ValueError('No private key found for sender') raise ValueError('No private key found for sender')

View file

@ -1,3 +1,4 @@
''' bring connectors into the namespace ''' ''' bring connectors into the namespace '''
from .settings import CONNECTORS from .settings import CONNECTORS
from .abstract_connector import ConnectorException from .abstract_connector import ConnectorException
from .abstract_connector import get_data, get_image

View file

@ -8,6 +8,7 @@ from django.db import transaction
from dateutil import parser from dateutil import parser
import requests import requests
from requests import HTTPError from requests import HTTPError
from requests.exceptions import SSLError
from bookwyrm import models from bookwyrm import models
@ -16,20 +17,13 @@ class ConnectorException(HTTPError):
''' when the connector can't do what was asked ''' ''' when the connector can't do what was asked '''
class AbstractConnector(ABC): class AbstractMinimalConnector(ABC):
''' generic book data connector ''' ''' just the bare bones, for other bookwyrm instances '''
def __init__(self, identifier): def __init__(self, identifier):
# load connector settings # load connector settings
info = models.Connector.objects.get(identifier=identifier) info = models.Connector.objects.get(identifier=identifier)
self.connector = info self.connector = info
self.key_mappings = []
# fields we want to look for in book data to copy over
# title we handle separately.
self.book_mappings = []
# the things in the connector model to copy over # the things in the connector model to copy over
self_fields = [ self_fields = [
'base_url', 'base_url',
@ -44,15 +38,6 @@ class AbstractConnector(ABC):
for field in self_fields: for field in self_fields:
setattr(self, field, getattr(info, field)) setattr(self, field, getattr(info, field))
def is_available(self):
''' check if you're allowed to use this connector '''
if self.max_query_count is not None:
if self.connector.query_count >= self.max_query_count:
return False
return True
def search(self, query, min_confidence=None): def search(self, query, min_confidence=None):
''' free text search ''' ''' free text search '''
resp = requests.get( resp = requests.get(
@ -70,9 +55,40 @@ class AbstractConnector(ABC):
results.append(self.format_search_result(doc)) results.append(self.format_search_result(doc))
return results return results
@abstractmethod
def get_or_create_book(self, remote_id): def get_or_create_book(self, remote_id):
''' pull up a book record by whatever means possible ''' ''' pull up a book record by whatever means possible '''
@abstractmethod
def parse_search_data(self, data):
''' turn the result json from a search into a list '''
@abstractmethod
def format_search_result(self, search_result):
''' create a SearchResult obj from json '''
class AbstractConnector(AbstractMinimalConnector):
''' generic book data connector '''
def __init__(self, identifier):
super().__init__(identifier)
self.key_mappings = []
# fields we want to look for in book data to copy over
# title we handle separately.
self.book_mappings = []
def is_available(self):
''' check if you're allowed to use this connector '''
if self.max_query_count is not None:
if self.connector.query_count >= self.max_query_count:
return False
return True
def get_or_create_book(self, remote_id):
# try to load the book # try to load the book
book = models.Book.objects.select_subclasses().filter( book = models.Book.objects.select_subclasses().filter(
origin_id=remote_id origin_id=remote_id
@ -157,13 +173,12 @@ class AbstractConnector(ABC):
def update_book_from_data(self, book, data, update_cover=True): def update_book_from_data(self, book, data, update_cover=True):
''' for creating a new book or syncing with data ''' ''' for creating a new book or syncing with data '''
book = self.update_from_mappings(book, data, self.book_mappings) book = update_from_mappings(book, data, self.book_mappings)
author_text = [] author_text = []
for author in self.get_authors_from_data(data): for author in self.get_authors_from_data(data):
book.authors.add(author) book.authors.add(author)
if author.display_name: author_text.append(author.name)
author_text.append(author.display_name)
book.author_text = ', '.join(author_text) book.author_text = ', '.join(author_text)
book.save() book.save()
@ -246,39 +261,28 @@ class AbstractConnector(ABC):
def get_cover_from_data(self, data): def get_cover_from_data(self, data):
''' load cover ''' ''' load cover '''
@abstractmethod
def parse_search_data(self, data):
''' turn the result json from a search into a list '''
@abstractmethod
def format_search_result(self, search_result):
''' create a SearchResult obj from json '''
@abstractmethod @abstractmethod
def expand_book_data(self, book): def expand_book_data(self, book):
''' get more info on a book ''' ''' get more info on a book '''
def update_from_mappings(self, obj, data, mappings): def update_from_mappings(obj, data, mappings):
''' assign data to model with mappings ''' ''' assign data to model with mappings '''
for mapping in mappings: for mapping in mappings:
# check if this field is present in the data # check if this field is present in the data
value = data.get(mapping.remote_field) value = data.get(mapping.remote_field)
if not value: if not value:
continue continue
# extract the value in the right format # extract the value in the right format
try: try:
value = mapping.formatter(value) value = mapping.formatter(value)
except: except:
continue continue
# assign the formatted value to the model # assign the formatted value to the model
obj.__setattr__(mapping.local_field, value) obj.__setattr__(mapping.local_field, value)
return obj return obj
def get_date(date_string): def get_date(date_string):
@ -310,10 +314,25 @@ def get_data(url):
raise ConnectorException() raise ConnectorException()
if not resp.ok: if not resp.ok:
resp.raise_for_status() resp.raise_for_status()
data = resp.json() try:
data = resp.json()
except ValueError:
raise ConnectorException()
return data return data
def get_image(url):
''' wrapper for requesting an image '''
try:
resp = requests.get(url)
except (RequestError, SSLError):
return None
if not resp.ok:
return None
return resp
@dataclass @dataclass
class SearchResult: class SearchResult:
''' standardized search result object ''' ''' standardized search result object '''

View file

@ -1,83 +1,16 @@
''' using another bookwyrm instance as a source of book data ''' ''' using another bookwyrm instance as a source of book data '''
from django.db import transaction
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
from .abstract_connector import AbstractConnector, SearchResult from .abstract_connector import AbstractMinimalConnector, SearchResult
from .abstract_connector import get_data
class Connector(AbstractConnector): class Connector(AbstractMinimalConnector):
''' interact with other instances ''' ''' this is basically just for search '''
def update_from_mappings(self, obj, data, mappings):
''' serialize book data into a model '''
if self.is_work_data(data):
work_data = activitypub.Work(**data)
return work_data.to_model(models.Work, instance=obj)
edition_data = activitypub.Edition(**data)
return edition_data.to_model(models.Edition, instance=obj)
def get_remote_id_from_data(self, data):
return data.get('id')
def is_work_data(self, data):
return data.get('type') == 'Work'
def get_edition_from_work_data(self, data):
''' we're served a list of edition urls '''
path = data['editions'][0]
return get_data(path)
def get_work_from_edition_date(self, data):
return get_data(data['work'])
def get_authors_from_data(self, data):
''' load author data '''
for author_id in data.get('authors', []):
try:
yield models.Author.objects.get(origin_id=author_id)
except models.Author.DoesNotExist:
pass
data = get_data(author_id)
author_data = activitypub.Author(**data)
author = author_data.to_model(models.Author)
yield author
def get_cover_from_data(self, data):
pass
def get_or_create_book(self, remote_id):
return activitypub.resolve_remote_id(models.Edition, remote_id)
def parse_search_data(self, data): def parse_search_data(self, data):
return data return data
def format_search_result(self, search_result): def format_search_result(self, search_result):
return SearchResult(**search_result) return SearchResult(**search_result)
def expand_book_data(self, book):
work = book
# go from the edition to the work, if necessary
if isinstance(book, models.Edition):
work = book.parent_work
# it may be that we actually want to request this url
editions_url = '%s/editions?page=true' % work.remote_id
edition_options = get_data(editions_url)
for edition_data in edition_options['orderedItems']:
with transaction.atomic():
edition = self.create_book(
edition_data['id'],
edition_data,
models.Edition
)
edition.parent_work = work
edition.save()
if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all())

View file

@ -7,7 +7,7 @@ from django.core.files.base import ContentFile
from bookwyrm import models from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import ConnectorException from .abstract_connector import ConnectorException
from .abstract_connector import get_date, get_data from .abstract_connector import get_date, get_data, update_from_mappings
from .openlibrary_languages import languages from .openlibrary_languages import languages
@ -65,6 +65,7 @@ class Connector(AbstractConnector):
] ]
self.author_mappings = [ self.author_mappings = [
Mapping('name'),
Mapping('born', remote_field='birth_date', formatter=get_date), Mapping('born', remote_field='birth_date', formatter=get_date),
Mapping('died', remote_field='death_date', formatter=get_date), Mapping('died', remote_field='death_date', formatter=get_date),
Mapping('bio', formatter=get_description), Mapping('bio', formatter=get_description),
@ -184,12 +185,7 @@ class Connector(AbstractConnector):
data = get_data(url) data = get_data(url)
author = models.Author(openlibrary_key=olkey) author = models.Author(openlibrary_key=olkey)
author = self.update_from_mappings(author, data, self.author_mappings) author = update_from_mappings(author, data, self.author_mappings)
name = data.get('name')
# TODO this is making some BOLD assumption
if name:
author.last_name = name.split(' ')[-1]
author.first_name = ' '.join(name.split(' ')[:-1])
author.save() author.save()
return author return author

View file

@ -1,6 +1,6 @@
''' handles all of the activity coming in to the server ''' ''' handles all of the activity coming in to the server '''
import json import json
from urllib.parse import urldefrag, unquote_plus from urllib.parse import urldefrag
import django.db.utils import django.db.utils
from django.http import HttpResponse from django.http import HttpResponse
@ -8,9 +8,8 @@ from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import requests import requests
from bookwyrm import activitypub, books_manager, models, outgoing from bookwyrm import activitypub, models, outgoing
from bookwyrm import status as status_builder from bookwyrm import status as status_builder
from bookwyrm.remote_user import get_or_create_remote_user, refresh_remote_user
from bookwyrm.tasks import app from bookwyrm.tasks import app
from bookwyrm.signatures import Signature from bookwyrm.signatures import Signature
@ -60,9 +59,8 @@ def shared_inbox(request):
'Like': handle_favorite, 'Like': handle_favorite,
'Announce': handle_boost, 'Announce': handle_boost,
'Add': { 'Add': {
'Tag': handle_tag, 'Edition': handle_add,
'Edition': handle_shelve, 'Work': handle_add,
'Work': handle_shelve,
}, },
'Undo': { 'Undo': {
'Follow': handle_unfollow, 'Follow': handle_unfollow,
@ -97,16 +95,20 @@ def has_valid_signature(request, activity):
if key_actor != activity.get('actor'): if key_actor != activity.get('actor'):
raise ValueError("Wrong actor created signature.") raise ValueError("Wrong actor created signature.")
remote_user = get_or_create_remote_user(key_actor) remote_user = activitypub.resolve_remote_id(models.User, key_actor)
if not remote_user:
return False
try: try:
signature.verify(remote_user.public_key, request) signature.verify(remote_user.key_pair.public_key, request)
except ValueError: except ValueError:
old_key = remote_user.public_key old_key = remote_user.key_pair.public_key
refresh_remote_user(remote_user) remote_user = activitypub.resolve_remote_id(
if remote_user.public_key == old_key: models.User, remote_user.remote_id, refresh=True
)
if remote_user.key_pair.public_key == old_key:
raise # Key unchanged. raise # Key unchanged.
signature.verify(remote_user.public_key, request) signature.verify(remote_user.key_pair.public_key, request)
except (ValueError, requests.exceptions.HTTPError): except (ValueError, requests.exceptions.HTTPError):
return False return False
return True return True
@ -115,26 +117,10 @@ def has_valid_signature(request, activity):
@app.task @app.task
def handle_follow(activity): def handle_follow(activity):
''' someone wants to follow a local user ''' ''' someone wants to follow a local user '''
# figure out who they want to follow -- not using get_or_create because
# we only care if you want to follow local users
try: try:
to_follow = models.User.objects.get(remote_id=activity['object']) relationship = activitypub.Follow(
except models.User.DoesNotExist: **activity
# some rando, who cares ).to_model(models.UserFollowRequest)
return
if not to_follow.local:
# just ignore follow alerts about other servers. maybe they should be
# handled. maybe they shouldn't be sent at all.
return
# figure out who the actor is
actor = get_or_create_remote_user(activity['actor'])
try:
relationship = models.UserFollowRequest.objects.create(
user_subject=actor,
user_object=to_follow,
remote_id=activity['id']
)
except django.db.utils.IntegrityError as err: except django.db.utils.IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique': if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise raise
@ -143,27 +129,22 @@ def handle_follow(activity):
) )
# send the accept normally for a duplicate request # send the accept normally for a duplicate request
if not to_follow.manually_approves_followers: manually_approves = relationship.user_object.manually_approves_followers
status_builder.create_notification(
to_follow, status_builder.create_notification(
'FOLLOW', relationship.user_object,
related_user=actor 'FOLLOW_REQUEST' if manually_approves else 'FOLLOW',
) related_user=relationship.user_subject
)
if not manually_approves:
outgoing.handle_accept(relationship) outgoing.handle_accept(relationship)
else:
# Accept will be triggered manually
status_builder.create_notification(
to_follow,
'FOLLOW_REQUEST',
related_user=actor
)
@app.task @app.task
def handle_unfollow(activity): def handle_unfollow(activity):
''' unfollow a local user ''' ''' unfollow a local user '''
obj = activity['object'] obj = activity['object']
requester = get_or_create_remote_user(obj['actor']) requester = activitypub.resolve_remote_id(models.user, obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object']) to_unfollow = models.User.objects.get(remote_id=obj['object'])
# raises models.User.DoesNotExist # raises models.User.DoesNotExist
@ -176,7 +157,7 @@ def handle_follow_accept(activity):
# figure out who they want to follow # figure out who they want to follow
requester = models.User.objects.get(remote_id=activity['object']['actor']) requester = models.User.objects.get(remote_id=activity['object']['actor'])
# figure out who they are # figure out who they are
accepter = get_or_create_remote_user(activity['actor']) accepter = activitypub.resolve_remote_id(models.User, activity['actor'])
try: try:
request = models.UserFollowRequest.objects.get( request = models.UserFollowRequest.objects.get(
@ -193,7 +174,7 @@ def handle_follow_accept(activity):
def handle_follow_reject(activity): def handle_follow_reject(activity):
''' someone is rejecting a follow request ''' ''' someone is rejecting a follow request '''
requester = models.User.objects.get(remote_id=activity['object']['actor']) requester = models.User.objects.get(remote_id=activity['object']['actor'])
rejecter = get_or_create_remote_user(activity['actor']) rejecter = activitypub.resolve_remote_id(models.User, activity['actor'])
request = models.UserFollowRequest.objects.get( request = models.UserFollowRequest.objects.get(
user_subject=requester, user_subject=requester,
@ -206,25 +187,27 @@ def handle_follow_reject(activity):
@app.task @app.task
def handle_create(activity): def handle_create(activity):
''' someone did something, good on them ''' ''' someone did something, good on them '''
if activity['object'].get('type') not in \
['Note', 'Comment', 'Quotation', 'Review', 'GeneratedNote']:
# if it's an article or unknown type, ignore it
return
user = get_or_create_remote_user(activity['actor'])
if user.local:
# we really oughtn't even be sending in this case
return
# deduplicate incoming activities # deduplicate incoming activities
status_id = activity['object']['id'] status_id = activity['object']['id']
if models.Status.objects.filter(remote_id=status_id).count(): if models.Status.objects.filter(remote_id=status_id).count():
return return
status = status_builder.create_status(activity['object']) serializer = activitypub.activity_objects[activity['type']]
if not status: status = serializer(**activity)
try:
model = models.activity_models[activity.type]
except KeyError:
# not a type of status we are prepared to deserialize
return return
if activity.type == 'Note':
reply = models.Status.objects.filter(
remote_id=activity.inReplyTo
).first()
if not reply:
return
activity.to_model(model)
# create a notification if this is a reply # create a notification if this is a reply
if status.reply_parent and status.reply_parent.user.local: if status.reply_parent and status.reply_parent.user.local:
status_builder.create_notification( status_builder.create_notification(
@ -258,16 +241,14 @@ def handle_favorite(activity):
''' approval of your good good post ''' ''' approval of your good good post '''
fav = activitypub.Like(**activity) fav = activitypub.Like(**activity)
liker = get_or_create_remote_user(activity['actor'])
if liker.local:
return
fav = fav.to_model(models.Favorite) fav = fav.to_model(models.Favorite)
if fav.user.local:
return
status_builder.create_notification( status_builder.create_notification(
fav.status.user, fav.status.user,
'FAVORITE', 'FAVORITE',
related_user=liker, related_user=fav.user,
related_status=fav.status, related_status=fav.status,
) )
@ -312,35 +293,13 @@ def handle_unboost(activity):
@app.task @app.task
def handle_tag(activity): def handle_add(activity):
''' someone is tagging a book '''
user = get_or_create_remote_user(activity['actor'])
if not user.local:
# ordered collection weirndess so we can't just to_model
book = books_manager.get_or_create_book(activity['object']['id'])
name = activity['object']['target'].split('/')[-1]
name = unquote_plus(name)
models.Tag.objects.get_or_create(
user=user,
book=book,
name=name
)
@app.task
def handle_shelve(activity):
''' putting a book on a shelf ''' ''' putting a book on a shelf '''
user = get_or_create_remote_user(activity['actor']) #this is janky as heck but I haven't thought of a better solution
book = books_manager.get_or_create_book(activity['object'])
try: try:
shelf = models.Shelf.objects.get(remote_id=activity['target']) activitypub.AddBook(**activity).to_model(models.ShelfBook)
except models.Shelf.DoesNotExist: except activitypub.ActivitySerializerError:
return activitypub.AddBook(**activity).to_model(models.Tag)
if shelf.user != user:
# this doesn't add up.
return
shelf.books.add(book)
shelf.save()
@app.task @app.task
@ -360,13 +319,4 @@ def handle_update_user(activity):
@app.task @app.task
def handle_update_book(activity): def handle_update_book(activity):
''' a remote instance changed a book (Document) ''' ''' a remote instance changed a book (Document) '''
document = activity['object'] activitypub.Edition(**activity['object']).to_model(models.Edition)
# check if we have their copy and care about their updates
book = models.Book.objects.select_subclasses().filter(
remote_id=document['id'],
sync=True,
).first()
if not book:
return
books_manager.update_book(book, data=document)

View file

@ -0,0 +1,63 @@
# Generated by Django 3.0.7 on 2020-11-29 03:04
import bookwyrm.utils.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0015_auto_20201128_0349'),
]
operations = [
migrations.AlterField(
model_name='book',
name='subject_places',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
),
migrations.AlterField(
model_name='book',
name='subjects',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
),
migrations.AlterField(
model_name='edition',
name='parent_work',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterUniqueTogether(
name='tag',
unique_together=set(),
),
migrations.RemoveField(
model_name='tag',
name='book',
),
migrations.RemoveField(
model_name='tag',
name='user',
),
migrations.CreateModel(
name='UserTag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', models.CharField(max_length=255, null=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'book', 'tag')},
},
),
]

View file

@ -0,0 +1,189 @@
# Generated by Django 3.0.7 on 2020-11-30 18:19
import bookwyrm.models.base_model
import bookwyrm.models.fields
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def copy_rsa_keys(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
users = app_registry.get_model('bookwyrm', 'User')
keypair = app_registry.get_model('bookwyrm', 'KeyPair')
for user in users.objects.using(db_alias):
if user.public_key or user.private_key:
user.key_pair = keypair.objects.create(
remote_id='%s/#main-key' % user.remote_id,
private_key=user.private_key,
public_key=user.public_key
)
user.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0016_auto_20201129_0304'),
]
operations = [
migrations.CreateModel(
name='KeyPair',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
('private_key', models.TextField(blank=True, null=True)),
('public_key', bookwyrm.models.fields.TextField(blank=True, null=True)),
],
options={
'abstract': False,
},
bases=(bookwyrm.models.base_model.ActivitypubMixin, models.Model),
),
migrations.AddField(
model_name='user',
name='followers',
field=bookwyrm.models.fields.ManyToManyField(related_name='following', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='author',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='book',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='connector',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='favorite',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='federatedserver',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='image',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='notification',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='readthrough',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='shelf',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='shelfbook',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='status',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='tag',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='user',
name='avatar',
field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='avatars/'),
),
migrations.AlterField(
model_name='user',
name='bookwyrm_user',
field=bookwyrm.models.fields.BooleanField(default=True),
),
migrations.AlterField(
model_name='user',
name='inbox',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='user',
name='local',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='user',
name='manually_approves_followers',
field=bookwyrm.models.fields.BooleanField(default=False),
),
migrations.AlterField(
model_name='user',
name='name',
field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True),
),
migrations.AlterField(
model_name='user',
name='outbox',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='user',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='user',
name='shared_inbox',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='user',
name='username',
field=bookwyrm.models.fields.UsernameField(),
),
migrations.AlterField(
model_name='userblocks',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='userfollowrequest',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='userfollows',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AlterField(
model_name='usertag',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
),
migrations.AddField(
model_name='user',
name='key_pair',
field=bookwyrm.models.fields.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owner', to='bookwyrm.KeyPair'),
),
migrations.RunPython(copy_rsa_keys),
]

View file

@ -0,0 +1,25 @@
# Generated by Django 3.0.7 on 2020-11-30 18:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0017_auto_20201130_1819'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='following',
),
migrations.RemoveField(
model_name='user',
name='private_key',
),
migrations.RemoveField(
model_name='user',
name='public_key',
),
]

View file

@ -0,0 +1,36 @@
# Generated by Django 3.0.7 on 2020-11-30 19:39
import bookwyrm.models.fields
from django.db import migrations
def update_notnull(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
users = app_registry.get_model('bookwyrm', 'User')
for user in users.objects.using(db_alias):
if user.name and user.summary:
continue
if not user.summary:
user.summary = ''
if not user.name:
user.name = ''
user.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0018_auto_20201130_1832'),
]
operations = [
migrations.RunPython(update_notnull),
migrations.AlterField(
model_name='user',
name='name',
field=bookwyrm.models.fields.CharField(default='', max_length=100),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.TextField(default=''),
),
]

View file

@ -0,0 +1,353 @@
# Generated by Django 3.0.7 on 2020-12-08 02:13
import bookwyrm.models.fields
from django.conf import settings
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0019_auto_20201130_1939'),
]
operations = [
migrations.AlterField(
model_name='author',
name='aliases',
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AlterField(
model_name='author',
name='bio',
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='author',
name='born',
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='author',
name='died',
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='author',
name='name',
field=bookwyrm.models.fields.CharField(max_length=255),
),
migrations.AlterField(
model_name='author',
name='openlibrary_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='author',
name='wikipedia_link',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='authors',
field=bookwyrm.models.fields.ManyToManyField(to='bookwyrm.Author'),
),
migrations.AlterField(
model_name='book',
name='cover',
field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='covers/'),
),
migrations.AlterField(
model_name='book',
name='description',
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='first_published_date',
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='goodreads_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='languages',
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AlterField(
model_name='book',
name='librarything_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='openlibrary_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='published_date',
field=bookwyrm.models.fields.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='series',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='series_number',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='sort_title',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='subject_places',
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
),
migrations.AlterField(
model_name='book',
name='subjects',
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
),
migrations.AlterField(
model_name='book',
name='subtitle',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='title',
field=bookwyrm.models.fields.CharField(max_length=255),
),
migrations.AlterField(
model_name='boost',
name='boosted_status',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'),
),
migrations.AlterField(
model_name='comment',
name='book',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='edition',
name='asin',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='isbn_10',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='isbn_13',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='oclc_number',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='pages',
field=bookwyrm.models.fields.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='edition',
name='parent_work',
field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'),
),
migrations.AlterField(
model_name='edition',
name='physical_format',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='publishers',
field=bookwyrm.models.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AlterField(
model_name='favorite',
name='status',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'),
),
migrations.AlterField(
model_name='favorite',
name='user',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='image',
name='caption',
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='image',
name='image',
field=bookwyrm.models.fields.ImageField(blank=True, null=True, upload_to='status/'),
),
migrations.AlterField(
model_name='quotation',
name='book',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='quotation',
name='quote',
field=bookwyrm.models.fields.TextField(),
),
migrations.AlterField(
model_name='review',
name='book',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='review',
name='name',
field=bookwyrm.models.fields.CharField(max_length=255, null=True),
),
migrations.AlterField(
model_name='review',
name='rating',
field=bookwyrm.models.fields.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]),
),
migrations.AlterField(
model_name='shelf',
name='name',
field=bookwyrm.models.fields.CharField(max_length=100),
),
migrations.AlterField(
model_name='shelf',
name='privacy',
field=bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
),
migrations.AlterField(
model_name='shelf',
name='user',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='shelfbook',
name='added_by',
field=bookwyrm.models.fields.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='shelfbook',
name='book',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='shelfbook',
name='shelf',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Shelf'),
),
migrations.AlterField(
model_name='status',
name='content',
field=bookwyrm.models.fields.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='status',
name='mention_books',
field=bookwyrm.models.fields.TagField(related_name='mention_book', to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='status',
name='mention_users',
field=bookwyrm.models.fields.TagField(related_name='mention_user', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='status',
name='published_date',
field=bookwyrm.models.fields.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='status',
name='reply_parent',
field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'),
),
migrations.AlterField(
model_name='status',
name='sensitive',
field=bookwyrm.models.fields.BooleanField(default=False),
),
migrations.AlterField(
model_name='status',
name='user',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='tag',
name='name',
field=bookwyrm.models.fields.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='userblocks',
name='user_object',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='userblocks',
name='user_subject',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='userfollowrequest',
name='user_object',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='userfollowrequest',
name='user_subject',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='userfollows',
name='user_object',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='userfollows',
name='user_subject',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='usertag',
name='book',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='usertag',
name='tag',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag'),
),
migrations.AlterField(
model_name='usertag',
name='user',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='work',
name='default_edition',
field=bookwyrm.models.fields.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='work',
name='lccn',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -0,0 +1,14 @@
# Generated by Django 3.0.7 on 2020-12-12 17:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0020_auto_20201208_0213'),
('bookwyrm', '0016_auto_20201211_2026'),
]
operations = [
]

View file

@ -0,0 +1,30 @@
# Generated by Django 3.0.7 on 2020-12-12 17:44
from django.db import migrations
def set_author_name(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
authors = app_registry.get_model('bookwyrm', 'Author')
for author in authors.objects.using(db_alias):
if not author.name:
author.name = '%s %s' % (author.first_name, author.last_name)
author.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0021_merge_20201212_1737'),
]
operations = [
migrations.RunPython(set_author_name),
migrations.RemoveField(
model_name='author',
name='first_name',
),
migrations.RemoveField(
model_name='author',
name='last_name',
),
]

View file

@ -12,9 +12,9 @@ from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough from .status import Favorite, Boost, Notification, ReadThrough
from .attachment import Image from .attachment import Image
from .tag import Tag from .tag import Tag, UserTag
from .user import User from .user import User, KeyPair
from .relationship import UserFollows, UserFollowRequest, UserBlocks from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .federated_server import FederatedServer from .federated_server import FederatedServer
@ -25,3 +25,8 @@ from .site import SiteSettings, SiteInvite, PasswordReset
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass) cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[1].activity_serializer.__name__: c[1] \ activity_models = {c[1].activity_serializer.__name__: c[1] \
for c in cls_members if hasattr(c[1], 'activity_serializer')} for c in cls_members if hasattr(c[1], 'activity_serializer')}
def to_activity(activity_json):
''' link up models and activities '''
activity_type = activity_json.get('type')
return activity_models[activity_type].to_activity(activity_json)

View file

@ -3,7 +3,8 @@ from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin from .base_model import ActivitypubMixin
from .base_model import ActivityMapping, BookWyrmModel from .base_model import BookWyrmModel
from . import fields
class Attachment(ActivitypubMixin, BookWyrmModel): class Attachment(ActivitypubMixin, BookWyrmModel):
@ -14,19 +15,16 @@ class Attachment(ActivitypubMixin, BookWyrmModel):
related_name='attachments', related_name='attachments',
null=True null=True
) )
reverse_unfurl = True
class Meta: class Meta:
''' one day we'll have other types of attachments besides images ''' ''' one day we'll have other types of attachments besides images '''
abstract = True abstract = True
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'image'),
ActivityMapping('name', 'caption'),
]
class Image(Attachment): class Image(Attachment):
''' an image attachment ''' ''' an image attachment '''
image = models.ImageField(upload_to='status/', null=True, blank=True) image = fields.ImageField(
caption = models.TextField(null=True, blank=True) upload_to='status/', null=True, blank=True, activitypub_field='url')
caption = fields.TextField(null=True, blank=True, activitypub_field='name')
activity_serializer = activitypub.Image activity_serializer = activitypub.Image

View file

@ -3,48 +3,41 @@ from django.db import models
from django.utils import timezone from django.utils import timezone
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.utils.fields import ArrayField from bookwyrm.settings import DOMAIN
from .base_model import ActivitypubMixin, ActivityMapping, BookWyrmModel from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class Author(ActivitypubMixin, BookWyrmModel): class Author(ActivitypubMixin, BookWyrmModel):
''' basic biographic info ''' ''' basic biographic info '''
origin_id = models.CharField(max_length=255, null=True) origin_id = models.CharField(max_length=255, null=True)
''' copy of an author from OL ''' openlibrary_key = fields.CharField(
openlibrary_key = models.CharField(max_length=255, blank=True, null=True) max_length=255, blank=True, null=True, deduplication_field=True)
sync = models.BooleanField(default=True) sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now) last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = models.CharField(max_length=255, blank=True, null=True) wikipedia_link = fields.CharField(max_length=255, blank=True, null=True, deduplication_field=True)
# idk probably other keys would be useful here? # idk probably other keys would be useful here?
born = models.DateTimeField(blank=True, null=True) born = fields.DateTimeField(blank=True, null=True)
died = models.DateTimeField(blank=True, null=True) died = fields.DateTimeField(blank=True, null=True)
name = models.CharField(max_length=255) name = fields.CharField(max_length=255)
last_name = models.CharField(max_length=255, blank=True, null=True) aliases = fields.ArrayField(
first_name = models.CharField(max_length=255, blank=True, null=True)
aliases = ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
bio = models.TextField(null=True, blank=True) bio = fields.TextField(null=True, blank=True)
@property def save(self, *args, **kwargs):
def display_name(self): ''' can't be abstract for query reasons, but you shouldn't USE it '''
''' Helper to return a displayable name''' if self.id and not self.remote_id:
if self.name: self.remote_id = self.get_remote_id()
return self.name
# don't want to return a spurious space if all of these are None if not self.id:
if self.first_name and self.last_name: self.origin_id = self.remote_id
return self.first_name + ' ' + self.last_name self.remote_id = None
return self.last_name or self.first_name return super().save(*args, **kwargs)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
return 'https://%s/author/%s' % (DOMAIN, self.id)
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('name', 'name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibraryKey', 'openlibrary_key'),
ActivityMapping('wikipediaLink', 'wikipedia_link'),
]
activity_serializer = activitypub.Author activity_serializer = activitypub.Author

View file

@ -1,20 +1,20 @@
''' base model with default fields ''' ''' base model with default fields '''
from datetime import datetime
from base64 import b64encode from base64 import b64encode
from dataclasses import dataclass from functools import reduce
from typing import Callable import operator
from uuid import uuid4 from uuid import uuid4
from urllib.parse import urlencode
from Crypto.PublicKey import RSA from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15 from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256 from Crypto.Hash import SHA256
from django.core.paginator import Paginator
from django.db import models from django.db import models
from django.db.models.fields.files import ImageFieldFile from django.db.models import Q
from django.dispatch import receiver from django.dispatch import receiver
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN, PAGE_LENGTH
from .fields import RemoteIdField
PrivacyLevels = models.TextChoices('Privacy', [ PrivacyLevels = models.TextChoices('Privacy', [
@ -28,7 +28,7 @@ class BookWyrmModel(models.Model):
''' shared fields ''' ''' shared fields '''
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
remote_id = models.CharField(max_length=255, null=True) remote_id = RemoteIdField(null=True, activitypub_field='id')
def get_remote_id(self): def get_remote_id(self):
''' generate a url that resolves to the local object ''' ''' generate a url that resolves to the local object '''
@ -53,58 +53,99 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
instance.save() instance.save()
def unfurl_related_field(related_field):
''' load reverse lookups (like public key owner or Status attachment '''
if hasattr(related_field, 'all'):
return [unfurl_related_field(i) for i in related_field.all()]
if related_field.reverse_unfurl:
return related_field.field_to_activity()
return related_field.remote_id
class ActivitypubMixin: class ActivitypubMixin:
''' add this mixin for models that are AP serializable ''' ''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {} activity_serializer = lambda: {}
reverse_unfurl = False
def to_activity(self, pure=False): @classmethod
''' convert from a model to an activity ''' def find_existing_by_remote_id(cls, remote_id):
if pure: ''' look up a remote id in the db '''
# works around bookwyrm-specific fields for vanilla AP services return cls.find_existing({'id': remote_id})
mappings = self.pure_activity_mappings
else:
# may include custom fields that bookwyrm instances will understand
mappings = self.activity_mappings
fields = {} @classmethod
for mapping in mappings: def find_existing(cls, data):
if not hasattr(self, mapping.model_key) or not mapping.activity_key: ''' compare data to fields that can be used for deduplation.
# this field on the model isn't serialized This always includes remote_id, but can also be unique identifiers
like an isbn for an edition '''
filters = []
for field in cls._meta.get_fields():
if not hasattr(field, 'deduplication_field') or \
not field.deduplication_field:
continue continue
value = getattr(self, mapping.model_key)
if hasattr(value, 'remote_id'):
# this is probably a foreign key field, which we want to
# serialize as just the remote_id url reference
value = value.remote_id
elif isinstance(value, datetime):
value = value.isoformat()
elif isinstance(value, ImageFieldFile):
value = image_formatter(value)
# run the custom formatter function set in the model value = data.get(field.activitypub_field)
formatted_value = mapping.activity_formatter(value) if not value:
if mapping.activity_key in fields and \ continue
isinstance(fields[mapping.activity_key], list): filters.append({field.name: value})
# there can be two database fields that map to the same AP list
# this happens in status tags, which combines user and book tags if hasattr(cls, 'origin_id') and 'id' in data:
fields[mapping.activity_key] += formatted_value # kinda janky, but this handles special case for books
filters.append({'origin_id': data['id']})
if not filters:
# if there are no deduplication fields, it will match the first
# item no matter what. this shouldn't happen but just in case.
return None
objects = cls.objects
if hasattr(objects, 'select_subclasses'):
objects = objects.select_subclasses()
# an OR operation on all the match fields
match = objects.filter(
reduce(
operator.or_, (Q(**f) for f in filters)
)
)
# there OUGHT to be only one match
return match.first()
def to_activity(self):
''' convert from a model to an activity '''
activity = {}
for field in self._meta.get_fields():
if not hasattr(field, 'field_to_activity'):
continue
value = field.field_to_activity(getattr(self, field.name))
if value is None:
continue
key = field.get_activitypub_field()
if key in activity and isinstance(activity[key], list):
# handles tags on status, which accumulate across fields
activity[key] += value
else: else:
fields[mapping.activity_key] = formatted_value activity[key] = value
if pure: if hasattr(self, 'serialize_reverse_fields'):
return self.pure_activity_serializer( # for example, editions of a work
**fields for model_field_name, activity_field_name in \
).serialize() self.serialize_reverse_fields:
return self.activity_serializer( related_field = getattr(self, model_field_name)
**fields activity[activity_field_name] = \
).serialize() unfurl_related_field(related_field)
if not activity.get('id'):
activity['id'] = self.get_remote_id()
return self.activity_serializer(**activity).serialize()
def to_create_activity(self, user, pure=False): def to_create_activity(self, user):
''' returns the object wrapped in a Create activity ''' ''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(pure=pure) activity_object = self.to_activity()
signer = pkcs1_15.new(RSA.import_key(user.private_key)) signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object['content'] content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8'))) signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity' create_id = self.remote_id + '/activity'
@ -118,8 +159,8 @@ class ActivitypubMixin:
return activitypub.Create( return activitypub.Create(
id=create_id, id=create_id,
actor=user.remote_id, actor=user.remote_id,
to=['%s/followers' % user.remote_id], to=activity_object['to'],
cc=['https://www.w3.org/ns/activitystreams#Public'], cc=activity_object['cc'],
object=activity_object, object=activity_object,
signature=signature, signature=signature,
).serialize() ).serialize()
@ -127,21 +168,18 @@ class ActivitypubMixin:
def to_delete_activity(self, user): def to_delete_activity(self, user):
''' notice of deletion ''' ''' notice of deletion '''
# this should be a tombstone
activity_object = self.to_activity()
return activitypub.Delete( return activitypub.Delete(
id=self.remote_id + '/activity', id=self.remote_id + '/activity',
actor=user.remote_id, actor=user.remote_id,
to=['%s/followers' % user.remote_id], to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'], cc=['https://www.w3.org/ns/activitystreams#Public'],
object=activity_object, object=self.to_activity(),
).serialize() ).serialize()
def to_update_activity(self, user): def to_update_activity(self, user):
''' wrapper for Updates to an activity ''' ''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (user.remote_id, uuid4()) activity_id = '%s#update/%s' % (self.remote_id, uuid4())
return activitypub.Update( return activitypub.Update(
id=activity_id, id=activity_id,
actor=user.remote_id, actor=user.remote_id,
@ -153,10 +191,10 @@ class ActivitypubMixin:
def to_undo_activity(self, user): def to_undo_activity(self, user):
''' undo an action ''' ''' undo an action '''
return activitypub.Undo( return activitypub.Undo(
id='%s#undo' % user.remote_id, id='%s#undo' % self.remote_id,
actor=user.remote_id, actor=user.remote_id,
object=self.to_activity() object=self.to_activity()
) ).serialize()
class OrderedCollectionPageMixin(ActivitypubMixin): class OrderedCollectionPageMixin(ActivitypubMixin):
@ -167,77 +205,53 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
''' this can be overriden if there's a special remote id, ie outbox ''' ''' this can be overriden if there's a special remote id, ie outbox '''
return self.remote_id return self.remote_id
def page(self, min_id=None, max_id=None):
''' helper function to create the pagination url '''
params = {'page': 'true'}
if min_id:
params['min_id'] = min_id
if max_id:
params['max_id'] = max_id
return '?%s' % urlencode(params)
def next_page(self, items):
''' use the max id of the last item '''
if not items.count():
return ''
return self.page(max_id=items[items.count() - 1].id)
def prev_page(self, items):
''' use the min id of the first item '''
if not items.count():
return ''
return self.page(min_id=items[0].id)
def to_ordered_collection_page(self, queryset, remote_id, \
id_only=False, min_id=None, max_id=None):
''' serialize and pagiante a queryset '''
# TODO: weird place to define this
limit = 20
# filters for use in the django queryset min/max
filters = {}
if min_id is not None:
filters['id__gt'] = min_id
if max_id is not None:
filters['id__lte'] = max_id
page_id = self.page(min_id=min_id, max_id=max_id)
items = queryset.filter(
**filters
).all()[:limit]
if id_only:
page = [s.remote_id for s in items]
else:
page = [s.to_activity() for s in items]
return activitypub.OrderedCollectionPage(
id='%s%s' % (remote_id, page_id),
partOf=remote_id,
orderedItems=page,
next='%s%s' % (remote_id, self.next_page(items)),
prev='%s%s' % (remote_id, self.prev_page(items))
).serialize()
def to_ordered_collection(self, queryset, \ def to_ordered_collection(self, queryset, \
remote_id=None, page=False, **kwargs): remote_id=None, page=False, **kwargs):
''' an ordered collection of whatevers ''' ''' an ordered collection of whatevers '''
remote_id = remote_id or self.remote_id remote_id = remote_id or self.remote_id
if page: if page:
return self.to_ordered_collection_page( return to_ordered_collection_page(
queryset, remote_id, **kwargs) queryset, remote_id, **kwargs)
name = '' name = self.name if hasattr(self, 'name') else None
if hasattr(self, 'name'): owner = self.user.remote_id if hasattr(self, 'user') else ''
name = self.name
size = queryset.count() paginated = Paginator(queryset, PAGE_LENGTH)
return activitypub.OrderedCollection( return activitypub.OrderedCollection(
id=remote_id, id=remote_id,
totalItems=size, totalItems=paginated.count,
name=name, name=name,
first='%s%s' % (remote_id, self.page()), owner=owner,
last='%s%s' % (remote_id, self.page(min_id=0)) first='%s?page=1' % remote_id,
last='%s?page=%d' % (remote_id, paginated.num_pages)
).serialize() ).serialize()
def to_ordered_collection_page(queryset, remote_id, id_only=False, page=1):
''' serialize and pagiante a queryset '''
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.page(page)
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:
items = [s.to_activity() for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '%s?page=%d' % \
(remote_id, activity_page.previous_page_number())
return activitypub.OrderedCollectionPage(
id='%s?page=%s' % (remote_id, page),
partOf=remote_id,
orderedItems=items,
next=next_page,
prev=prev_page
).serialize()
class OrderedCollectionMixin(OrderedCollectionPageMixin): class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections ''' ''' extends activitypub models to work as ordered collections '''
@property @property
@ -250,39 +264,3 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
def to_activity(self, **kwargs): def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset ''' ''' an ordered collection of the specified model queryset '''
return self.to_ordered_collection(self.collection_queryset, **kwargs) return self.to_ordered_collection(self.collection_queryset, **kwargs)
@dataclass(frozen=True)
class ActivityMapping:
''' translate between an activitypub json field and a model field '''
activity_key: str
model_key: str
activity_formatter: Callable = lambda x: x
model_formatter: Callable = lambda x: x
def tag_formatter(items, name_field, activity_type):
''' helper function to format lists of foreign keys into Tags '''
tags = []
for item in items.all():
tags.append(activitypub.Link(
href=item.remote_id,
name=getattr(item, name_field),
type=activity_type
))
return tags
def image_formatter(image):
''' convert images into activitypub json '''
if image and hasattr(image, 'url'):
url = image.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
def image_attachments_formatter(images):
''' create a list of image attachments '''
return [image_formatter(i) for i in images]

View file

@ -2,24 +2,26 @@
import re import re
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.utils.fields import ArrayField
from .base_model import ActivityMapping, BookWyrmModel from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from . import fields
class Book(ActivitypubMixin, BookWyrmModel): class Book(ActivitypubMixin, BookWyrmModel):
''' a generic book, which can mean either an edition or a work ''' ''' a generic book, which can mean either an edition or a work '''
origin_id = models.CharField(max_length=255, null=True, blank=True) origin_id = models.CharField(max_length=255, null=True, blank=True)
# these identifiers apply to both works and editions # these identifiers apply to both works and editions
openlibrary_key = models.CharField(max_length=255, blank=True, null=True) openlibrary_key = fields.CharField(
librarything_key = models.CharField(max_length=255, blank=True, null=True) max_length=255, blank=True, null=True, deduplication_field=True)
goodreads_key = models.CharField(max_length=255, blank=True, null=True) librarything_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
goodreads_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
# info about where the data comes from and where/if to sync # info about where the data comes from and where/if to sync
sync = models.BooleanField(default=True) sync = models.BooleanField(default=True)
@ -31,78 +33,43 @@ class Book(ActivitypubMixin, BookWyrmModel):
# TODO: edit history # TODO: edit history
# book/work metadata # book/work metadata
title = models.CharField(max_length=255) title = fields.CharField(max_length=255)
sort_title = models.CharField(max_length=255, blank=True, null=True) sort_title = fields.CharField(max_length=255, blank=True, null=True)
subtitle = models.CharField(max_length=255, blank=True, null=True) subtitle = fields.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True) description = fields.TextField(blank=True, null=True)
languages = ArrayField( languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
series = models.CharField(max_length=255, blank=True, null=True) series = fields.CharField(max_length=255, blank=True, null=True)
series_number = models.CharField(max_length=255, blank=True, null=True) series_number = fields.CharField(max_length=255, blank=True, null=True)
subjects = ArrayField( subjects = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, null=True, default=list
) )
subject_places = ArrayField( subject_places = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, null=True, default=list
) )
# TODO: include an annotation about the type of authorship (ie, translator) # TODO: include an annotation about the type of authorship (ie, translator)
authors = models.ManyToManyField('Author') authors = fields.ManyToManyField('Author')
# preformatted authorship string for search and easier display # preformatted authorship string for search and easier display
author_text = models.CharField(max_length=255, blank=True, null=True) author_text = models.CharField(max_length=255, blank=True, null=True)
cover = models.ImageField(upload_to='covers/', blank=True, null=True) cover = fields.ImageField(upload_to='covers/', blank=True, null=True)
first_published_date = models.DateTimeField(blank=True, null=True) first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = models.DateTimeField(blank=True, null=True) published_date = fields.DateTimeField(blank=True, null=True)
objects = InheritanceManager() objects = InheritanceManager()
@property
def ap_authors(self):
''' the activitypub serialization should be a list of author ids '''
return [a.remote_id for a in self.authors.all()]
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('authors', 'ap_authors'),
ActivityMapping('firstPublishedDate', 'firstpublished_date'),
ActivityMapping('publishedDate', 'published_date'),
ActivityMapping('title', 'title'),
ActivityMapping('sortTitle', 'sort_title'),
ActivityMapping('subtitle', 'subtitle'),
ActivityMapping('description', 'description'),
ActivityMapping('languages', 'languages'),
ActivityMapping('series', 'series'),
ActivityMapping('seriesNumber', 'series_number'),
ActivityMapping('subjects', 'subjects'),
ActivityMapping('subjectPlaces', 'subject_places'),
ActivityMapping('openlibraryKey', 'openlibrary_key'),
ActivityMapping('librarythingKey', 'librarything_key'),
ActivityMapping('goodreadsKey', 'goodreads_key'),
ActivityMapping('work', 'parent_work'),
ActivityMapping('isbn10', 'isbn_10'),
ActivityMapping('isbn13', 'isbn_13'),
ActivityMapping('oclcNumber', 'oclc_number'),
ActivityMapping('asin', 'asin'),
ActivityMapping('pages', 'pages'),
ActivityMapping('physicalFormat', 'physical_format'),
ActivityMapping('publishers', 'publishers'),
ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'),
ActivityMapping('cover', 'cover'),
]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it ''' ''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work): if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works') raise ValueError('Books should be added as Editions or Works')
if self.id and not self.remote_id: if self.id and not self.remote_id:
self.remote_id = self.get_remote_id() self.remote_id = self.get_remote_id()
super().save(*args, **kwargs) if not self.id:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs)
def get_remote_id(self): def get_remote_id(self):
''' editions and works both use "book" instead of model_name ''' ''' editions and works both use "book" instead of model_name '''
@ -119,47 +86,38 @@ class Book(ActivitypubMixin, BookWyrmModel):
class Work(OrderedCollectionPageMixin, Book): class Work(OrderedCollectionPageMixin, Book):
''' a work (an abstract concept of a book that manifests in an edition) ''' ''' a work (an abstract concept of a book that manifests in an edition) '''
# library of congress catalog control number # library of congress catalog control number
lccn = models.CharField(max_length=255, blank=True, null=True) lccn = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
# this has to be nullable but should never be null # this has to be nullable but should never be null
default_edition = models.ForeignKey( default_edition = fields.ForeignKey(
'Edition', 'Edition',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True null=True
) )
@property def get_default_edition(self):
def editions_path(self): ''' in case the default edition is not set '''
''' it'd be nice to serialize the edition instead but, recursion ''' return self.default_edition or self.editions.first()
default = self.default_edition
ed_list = [
e.remote_id for e in self.edition_set.filter(~Q(id=default.id)).all()
]
return [default.remote_id] + ed_list
def to_edition_list(self, **kwargs):
''' activitypub serialization for this work's editions '''
remote_id = self.remote_id + '/editions'
return self.to_ordered_collection(
self.edition_set,
remote_id=remote_id,
**kwargs
)
activity_serializer = activitypub.Work activity_serializer = activitypub.Work
serialize_reverse_fields = [('editions', 'editions')]
deserialize_reverse_fields = [('editions', 'editions')]
class Edition(Book): class Edition(Book):
''' an edition of a book ''' ''' an edition of a book '''
# these identifiers only apply to editions, not works # these identifiers only apply to editions, not works
isbn_10 = models.CharField(max_length=255, blank=True, null=True) isbn_10 = fields.CharField(
isbn_13 = models.CharField(max_length=255, blank=True, null=True) max_length=255, blank=True, null=True, deduplication_field=True)
oclc_number = models.CharField(max_length=255, blank=True, null=True) isbn_13 = fields.CharField(
asin = models.CharField(max_length=255, blank=True, null=True) max_length=255, blank=True, null=True, deduplication_field=True)
pages = models.IntegerField(blank=True, null=True) oclc_number = fields.CharField(
physical_format = models.CharField(max_length=255, blank=True, null=True) max_length=255, blank=True, null=True, deduplication_field=True)
publishers = ArrayField( asin = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
pages = fields.IntegerField(blank=True, null=True)
physical_format = fields.CharField(max_length=255, blank=True, null=True)
publishers = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list models.CharField(max_length=255), blank=True, default=list
) )
shelves = models.ManyToManyField( shelves = models.ManyToManyField(
@ -168,9 +126,12 @@ class Edition(Book):
through='ShelfBook', through='ShelfBook',
through_fields=('book', 'shelf') through_fields=('book', 'shelf')
) )
parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True) parent_work = fields.ForeignKey(
'Work', on_delete=models.PROTECT, null=True,
related_name='editions', activitypub_field='work')
activity_serializer = activitypub.Edition activity_serializer = activitypub.Edition
name_field = 'title'
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' calculate isbn 10/13 ''' ''' calculate isbn 10/13 '''

273
bookwyrm/models/fields.py Normal file
View file

@ -0,0 +1,273 @@
''' activitypub-aware django model fields '''
import re
from uuid import uuid4
import dateutil.parser
from dateutil.parser import ParserError
from django.contrib.auth.models import AbstractUser
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.connectors import get_image
def validate_remote_id(value):
''' make sure the remote_id looks like a url '''
if not value or not re.match(r'^http.?:\/\/[^\s]+$', value):
raise ValidationError(
_('%(value)s is not a valid remote_id'),
params={'value': value},
)
class ActivitypubFieldMixin:
''' make a database field serializable '''
def __init__(self, *args, \
activitypub_field=None, activitypub_wrapper=None,
deduplication_field=False, **kwargs):
self.deduplication_field = deduplication_field
if activitypub_wrapper:
self.activitypub_wrapper = activitypub_field
self.activitypub_field = activitypub_wrapper
else:
self.activitypub_field = activitypub_field
super().__init__(*args, **kwargs)
def field_to_activity(self, value):
''' formatter to convert a model value into activitypub '''
if hasattr(self, 'activitypub_wrapper'):
return {self.activitypub_wrapper: value}
return value
def field_from_activity(self, value):
''' formatter to convert activitypub into a model value '''
if hasattr(self, 'activitypub_wrapper'):
value = value.get(self.activitypub_wrapper)
return value
def get_activitypub_field(self):
''' model_field_name to activitypubFieldName '''
if self.activitypub_field:
return self.activitypub_field
name = self.name.split('.')[-1]
components = name.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
''' default (de)serialization for foreign key and one to one '''
def field_from_activity(self, value):
if not value:
return None
related_model = self.related_model
if isinstance(value, dict) and value.get('id'):
# this is an activitypub object, which we can deserialize
activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model)
try:
# make sure the value looks like a remote id
validate_remote_id(value)
except ValidationError:
# we don't know what this is, ignore it
return None
# gets or creates the model field from the remote id
return activitypub.resolve_remote_id(related_model, value)
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
''' a url that serves as a unique identifier '''
def __init__(self, *args, max_length=255, validators=None, **kwargs):
validators = validators or [validate_remote_id]
super().__init__(
*args, max_length=max_length, validators=validators,
**kwargs
)
# for this field, the default is true. false everywhere else.
self.deduplication_field = kwargs.get('deduplication_field', True)
class UsernameField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware username field '''
def __init__(self, activitypub_field='preferredUsername'):
self.activitypub_field = activitypub_field
# I don't totally know why pylint is mad at this, but it makes it work
super( #pylint: disable=bad-super-call
ActivitypubFieldMixin, self
).__init__(
_('username'),
max_length=150,
unique=True,
validators=[AbstractUser.username_validator],
error_messages={
'unique': _('A user with that username already exists.'),
},
)
def deconstruct(self):
''' implementation of models.Field deconstruct '''
name, path, args, kwargs = super().deconstruct()
del kwargs['verbose_name']
del kwargs['max_length']
del kwargs['unique']
del kwargs['validators']
del kwargs['error_messages']
return name, path, args, kwargs
def field_to_activity(self, value):
return value.split('@')[0]
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
''' activitypub-aware foreign key field '''
def field_to_activity(self, value):
if not value:
return None
return value.remote_id
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
''' activitypub-aware foreign key field '''
def field_to_activity(self, value):
if not value:
return None
return value.to_activity()
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
''' activitypub-aware many to many field '''
def __init__(self, *args, link_only=False, **kwargs):
self.link_only = link_only
super().__init__(*args, **kwargs)
def field_to_activity(self, value):
if self.link_only:
return '%s/%s' % (value.instance.remote_id, self.name)
return [i.remote_id for i in value.all()]
def field_from_activity(self, value):
items = []
for remote_id in value:
try:
validate_remote_id(remote_id)
except ValidationError:
continue
items.append(
activitypub.resolve_remote_id(self.related_model, remote_id)
)
return items
class TagField(ManyToManyField):
''' special case of many to many that uses Tags '''
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.activitypub_field = 'tag'
def field_to_activity(self, value):
tags = []
for item in value.all():
activity_type = item.__class__.__name__
if activity_type == 'User':
activity_type = 'Mention'
tags.append(activitypub.Link(
href=item.remote_id,
name=getattr(item, item.name_field),
type=activity_type
))
return tags
def field_from_activity(self, value):
if not isinstance(value, list):
return None
items = []
for link_json in value:
link = activitypub.Link(**link_json)
tag_type = link.type if link.type != 'Mention' else 'Person'
if tag_type != self.related_model.activity_serializer.type:
# tags can contain multiple types
continue
items.append(
activitypub.resolve_remote_id(self.related_model, link.href)
)
return items
def image_serializer(value):
''' helper for serializing images '''
if value and hasattr(value, 'url'):
url = value.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
class ImageField(ActivitypubFieldMixin, models.ImageField):
''' activitypub-aware image field '''
def field_to_activity(self, value):
return image_serializer(value)
def field_from_activity(self, value):
image_slug = value
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
if isinstance(image_slug, dict):
url = image_slug.get('url')
elif isinstance(image_slug, str):
url = image_slug
else:
return None
try:
validate_remote_id(url)
except ValidationError:
return None
response = get_image(url)
if not response:
return None
image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
''' activitypub-aware datetime field '''
def field_to_activity(self, value):
if not value:
return None
return value.isoformat()
def field_from_activity(self, value):
try:
date_value = dateutil.parser.parse(value)
try:
return timezone.make_aware(date_value)
except ValueError:
return date_value
except (ParserError, TypeError):
return None
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
''' activitypub-aware array field '''
def field_to_activity(self, value):
return [str(i) for i in value]
class CharField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware char field '''
class TextField(ActivitypubFieldMixin, models.TextField):
''' activitypub-aware text field '''
class BooleanField(ActivitypubFieldMixin, models.BooleanField):
''' activitypub-aware boolean field '''
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
''' activitypub-aware boolean field '''

View file

@ -2,20 +2,23 @@
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin, ActivityMapping, BookWyrmModel from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class UserRelationship(ActivitypubMixin, BookWyrmModel): class UserRelationship(ActivitypubMixin, BookWyrmModel):
''' many-to-many through table for followers ''' ''' many-to-many through table for followers '''
user_subject = models.ForeignKey( user_subject = fields.ForeignKey(
'User', 'User',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='%(class)s_user_subject' related_name='%(class)s_user_subject',
activitypub_field='actor',
) )
user_object = models.ForeignKey( user_object = fields.ForeignKey(
'User', 'User',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='%(class)s_user_object' related_name='%(class)s_user_object',
activitypub_field='object',
) )
class Meta: class Meta:
@ -32,11 +35,6 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
) )
] ]
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user_subject'),
ActivityMapping('object', 'user_object'),
]
activity_serializer = activitypub.Follow activity_serializer = activitypub.Follow
def get_remote_id(self, status=None): def get_remote_id(self, status=None):

View file

@ -3,16 +3,19 @@ import re
from django.db import models from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import BookWyrmModel, OrderedCollectionMixin, PrivacyLevels from .base_model import BookWyrmModel
from .base_model import OrderedCollectionMixin, PrivacyLevels
from . import fields
class Shelf(OrderedCollectionMixin, BookWyrmModel): class Shelf(OrderedCollectionMixin, BookWyrmModel):
''' a list of books owned by a user ''' ''' a list of books owned by a user '''
name = models.CharField(max_length=100) name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100) identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT) user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='owner')
editable = models.BooleanField(default=True) editable = models.BooleanField(default=True)
privacy = models.CharField( privacy = fields.CharField(
max_length=255, max_length=255,
default='public', default='public',
choices=PrivacyLevels.choices choices=PrivacyLevels.choices
@ -50,15 +53,20 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
class ShelfBook(BookWyrmModel): class ShelfBook(BookWyrmModel):
''' many to many join table for books and shelves ''' ''' many to many join table for books and shelves '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = fields.ForeignKey(
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) 'Edition', on_delete=models.PROTECT, activitypub_field='object')
added_by = models.ForeignKey( shelf = fields.ForeignKey(
'Shelf', on_delete=models.PROTECT, activitypub_field='target')
added_by = fields.ForeignKey(
'User', 'User',
blank=True, blank=True,
null=True, null=True,
on_delete=models.PROTECT on_delete=models.PROTECT,
activitypub_field='actor'
) )
activity_serializer = activitypub.AddBook
def to_add_activity(self, user): def to_add_activity(self, user):
''' AP for shelving a book''' ''' AP for shelving a book'''
return activitypub.Add( return activitypub.Add(

View file

@ -6,26 +6,27 @@ from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import ActivityMapping, BookWyrmModel, PrivacyLevels from .base_model import BookWyrmModel, PrivacyLevels
from .base_model import tag_formatter, image_attachments_formatter from . import fields
from .fields import image_serializer
class Status(OrderedCollectionPageMixin, BookWyrmModel): class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc ''' ''' any post, like a reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = fields.ForeignKey(
content = models.TextField(blank=True, null=True) 'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
mention_users = models.ManyToManyField('User', related_name='mention_user') content = fields.TextField(blank=True, null=True)
mention_books = models.ManyToManyField( mention_users = fields.TagField('User', related_name='mention_user')
'Edition', related_name='mention_book') mention_books = fields.TagField('Edition', related_name='mention_book')
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
privacy = models.CharField( privacy = models.CharField(
max_length=255, max_length=255,
default='public', default='public',
choices=PrivacyLevels.choices choices=PrivacyLevels.choices
) )
sensitive = models.BooleanField(default=False) sensitive = fields.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts # the created date can't be this, because of receiving federated posts
published_date = models.DateTimeField(default=timezone.now) published_date = fields.DateTimeField(
default=timezone.now, activitypub_field='published')
deleted = models.BooleanField(default=False) deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True) deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField( favorites = models.ManyToManyField(
@ -35,81 +36,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
through_fields=('status', 'user'), through_fields=('status', 'user'),
related_name='user_favorites' related_name='user_favorites'
) )
reply_parent = models.ForeignKey( reply_parent = fields.ForeignKey(
'self', 'self',
null=True, null=True,
on_delete=models.PROTECT on_delete=models.PROTECT,
activitypub_field='inReplyTo',
) )
objects = InheritanceManager() objects = InheritanceManager()
# ---- activitypub serialization settings for this model ----- #
@property
def ap_to(self):
''' should be related to post privacy I think '''
return ['https://www.w3.org/ns/activitystreams#Public']
@property
def ap_cc(self):
''' should be related to post privacy I think '''
return [self.user.ap_followers]
@property
def ap_replies(self):
''' structured replies block '''
return self.to_replies()
@property
def ap_status_image(self):
''' attach a book cover, if relevent '''
if hasattr(self, 'book'):
return self.book.ap_cover
if self.mention_books.first():
return self.mention_books.first().ap_cover
return None
shared_mappings = [
ActivityMapping('url', 'remote_id', lambda x: None),
ActivityMapping('id', 'remote_id'),
ActivityMapping('inReplyTo', 'reply_parent'),
ActivityMapping('published', 'published_date'),
ActivityMapping('attributedTo', 'user'),
ActivityMapping('to', 'ap_to'),
ActivityMapping('cc', 'ap_cc'),
ActivityMapping('replies', 'ap_replies'),
ActivityMapping(
'tag', 'mention_books',
lambda x: tag_formatter(x, 'title', 'Book'),
lambda x: activitypub.tag_formatter(x, 'Book')
),
ActivityMapping(
'tag', 'mention_users',
lambda x: tag_formatter(x, 'username', 'Mention'),
lambda x: activitypub.tag_formatter(x, 'Mention')
),
ActivityMapping(
'attachment', 'attachments',
lambda x: image_attachments_formatter(x.all()),
)
]
# serializing to bookwyrm expanded activitypub
activity_mappings = shared_mappings + [
ActivityMapping('name', 'name'),
ActivityMapping('inReplyToBook', 'book'),
ActivityMapping('rating', 'rating'),
ActivityMapping('quote', 'quote'),
ActivityMapping('content', 'content'),
]
# for serializing to standard activitypub without extended types
pure_activity_mappings = shared_mappings + [
ActivityMapping('name', 'ap_pure_name'),
ActivityMapping('content', 'ap_pure_content'),
ActivityMapping('attachment', 'ap_status_image'),
]
activity_serializer = activitypub.Note activity_serializer = activitypub.Note
serialize_reverse_fields = [('attachments', 'attachment')]
deserialize_reverse_fields = [('attachments', 'attachment')]
#----- replies collection activitypub ----# #----- replies collection activitypub ----#
@classmethod @classmethod
@ -140,7 +77,43 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
deleted=self.deleted_date.isoformat(), deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat() published=self.deleted_date.isoformat()
).serialize() ).serialize()
return ActivitypubMixin.to_activity(self, pure=pure) activity = ActivitypubMixin.to_activity(self)
activity['replies'] = self.to_replies()
# privacy controls
public = 'https://www.w3.org/ns/activitystreams#Public'
mentions = [u.remote_id for u in self.mention_users.all()]
# this is a link to the followers list:
followers = self.user.__class__._meta.get_field('followers')\
.field_to_activity(self.user.followers)
if self.privacy == 'public':
activity['to'] = [public]
activity['cc'] = [followers] + mentions
elif self.privacy == 'unlisted':
activity['to'] = [followers]
activity['cc'] = [public] + mentions
elif self.privacy == 'followers':
activity['to'] = [followers]
activity['cc'] = mentions
if self.privacy == 'direct':
activity['to'] = mentions
activity['cc'] = []
# "pure" serialization for non-bookwyrm instances
if pure:
activity['content'] = self.pure_content
if 'name' in activity:
activity['name'] = self.pure_name
activity['type'] = self.pure_type
activity['attachment'] = [
image_serializer(b.cover) for b in self.mention_books.all() \
if b.cover]
if hasattr(self, 'book'):
activity['attachment'].append(
image_serializer(self.book.cover)
)
return activity
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' update user active time ''' ''' update user active time '''
@ -153,40 +126,42 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
class GeneratedNote(Status): class GeneratedNote(Status):
''' these are app-generated messages about user activity ''' ''' these are app-generated messages about user activity '''
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
message = self.content message = self.content
books = ', '.join( books = ', '.join(
'<a href="%s">"%s"</a>' % (self.book.remote_id, self.book.title) \ '<a href="%s">"%s"</a>' % (book.remote_id, book.title) \
for book in self.mention_books.all() for book in self.mention_books.all()
) )
return '%s %s' % (message, books) return '%s %s %s' % (self.user.display_name, message, books)
activity_serializer = activitypub.GeneratedNote activity_serializer = activitypub.GeneratedNote
pure_activity_serializer = activitypub.Note pure_type = 'Note'
class Comment(Status): class Comment(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \ return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(self.book.remote_id, self.book.title) (self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note pure_type = 'Note'
class Quotation(Status): class Quotation(Status):
''' like a review but without a rating and transient ''' ''' like a review but without a rating and transient '''
quote = models.TextField() quote = fields.TextField()
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % ( return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % (
self.quote, self.quote,
@ -196,14 +171,15 @@ class Quotation(Status):
) )
activity_serializer = activitypub.Quotation activity_serializer = activitypub.Quotation
pure_activity_serializer = activitypub.Note pure_type = 'Note'
class Review(Status): class Review(Status):
''' a book review ''' ''' a book review '''
name = models.CharField(max_length=255, null=True) name = fields.CharField(max_length=255, null=True)
book = models.ForeignKey('Edition', on_delete=models.PROTECT) book = fields.ForeignKey(
rating = models.IntegerField( 'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
rating = fields.IntegerField(
default=None, default=None,
null=True, null=True,
blank=True, blank=True,
@ -211,7 +187,7 @@ class Review(Status):
) )
@property @property
def ap_pure_name(self): def pure_name(self):
''' clarify review names for mastodon serialization ''' ''' clarify review names for mastodon serialization '''
if self.rating: if self.rating:
return 'Review of "%s" (%d stars): %s' % ( return 'Review of "%s" (%d stars): %s' % (
@ -225,26 +201,21 @@ class Review(Status):
) )
@property @property
def ap_pure_content(self): def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users ''' ''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \ return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.remote_id, self.book.title) (self.book.remote_id, self.book.title)
activity_serializer = activitypub.Review activity_serializer = activitypub.Review
pure_activity_serializer = activitypub.Article pure_type = 'Article'
class Favorite(ActivitypubMixin, BookWyrmModel): class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post ''' ''' fav'ing a post '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = fields.ForeignKey(
status = models.ForeignKey('Status', on_delete=models.PROTECT) 'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
# ---- activitypub serialization settings for this model ----- # 'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'status'),
]
activity_serializer = activitypub.Like activity_serializer = activitypub.Like
@ -254,7 +225,6 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
self.user.save() self.user.save()
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Meta: class Meta:
''' can't fav things twice ''' ''' can't fav things twice '''
unique_together = ('user', 'status') unique_together = ('user', 'status')
@ -262,16 +232,12 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
class Boost(Status): class Boost(Status):
''' boost'ing a post ''' ''' boost'ing a post '''
boosted_status = models.ForeignKey( boosted_status = fields.ForeignKey(
'Status', 'Status',
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name="boosters") related_name='boosters',
activitypub_field='object',
activity_mappings = [ )
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'boosted_status'),
]
activity_serializer = activitypub.Boost activity_serializer = activitypub.Boost

View file

@ -6,13 +6,12 @@ from django.db import models
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from .base_model import OrderedCollectionMixin, BookWyrmModel from .base_model import OrderedCollectionMixin, BookWyrmModel
from . import fields
class Tag(OrderedCollectionMixin, BookWyrmModel): class Tag(OrderedCollectionMixin, BookWyrmModel):
''' freeform tags for books ''' ''' freeform tags for books '''
user = models.ForeignKey('User', on_delete=models.PROTECT) name = fields.CharField(max_length=100, unique=True)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100) identifier = models.CharField(max_length=100)
@classmethod @classmethod
@ -30,6 +29,26 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
base_path = 'https://%s' % DOMAIN base_path = 'https://%s' % DOMAIN
return '%s/tag/%s' % (base_path, self.identifier) return '%s/tag/%s' % (base_path, self.identifier)
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class UserTag(BookWyrmModel):
''' an instance of a tag on a book by a user '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
tag = fields.ForeignKey(
'Tag', on_delete=models.PROTECT, activitypub_field='target')
activity_serializer = activitypub.AddBook
def to_add_activity(self, user): def to_add_activity(self, user):
''' AP for shelving a book''' ''' AP for shelving a book'''
return activitypub.Add( return activitypub.Add(
@ -48,13 +67,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
target=self.to_activity(), target=self.to_activity(),
).serialize() ).serialize()
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class Meta: class Meta:
''' unqiueness constraint ''' ''' unqiueness constraint '''
unique_together = ('user', 'book', 'name') unique_together = ('user', 'book', 'tag')

View file

@ -6,44 +6,61 @@ from django.db import models
from django.dispatch import receiver from django.dispatch import receiver
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm.connectors import get_data
from bookwyrm.models.shelf import Shelf from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.signatures import create_key_pair from bookwyrm.signatures import create_key_pair
from .base_model import ActivityMapping, OrderedCollectionPageMixin from bookwyrm.tasks import app
from .base_model import image_formatter from .base_model import OrderedCollectionPageMixin
from .base_model import ActivitypubMixin, BookWyrmModel
from .federated_server import FederatedServer
from . import fields
class User(OrderedCollectionPageMixin, AbstractUser): class User(OrderedCollectionPageMixin, AbstractUser):
''' a user who wants to read books ''' ''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True) username = fields.UsernameField()
public_key = models.TextField(blank=True, null=True)
inbox = models.CharField(max_length=255, unique=True) key_pair = fields.OneToOneField(
shared_inbox = models.CharField(max_length=255, blank=True, null=True) 'KeyPair',
on_delete=models.CASCADE,
blank=True, null=True,
activitypub_field='publicKey',
related_name='owner'
)
inbox = fields.RemoteIdField(unique=True)
shared_inbox = fields.RemoteIdField(
activitypub_field='sharedInbox',
activitypub_wrapper='endpoints',
deduplication_field=False,
null=True)
federated_server = models.ForeignKey( federated_server = models.ForeignKey(
'FederatedServer', 'FederatedServer',
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
) )
outbox = models.CharField(max_length=255, unique=True) outbox = fields.RemoteIdField(unique=True)
summary = models.TextField(blank=True, null=True) summary = fields.TextField(default='')
local = models.BooleanField(default=True) local = models.BooleanField(default=False)
bookwyrm_user = models.BooleanField(default=True) bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField( localname = models.CharField(
max_length=255, max_length=255,
null=True, null=True,
unique=True unique=True
) )
# name is your display name, which you can change at will # name is your display name, which you can change at will
name = models.CharField(max_length=100, blank=True, null=True) name = fields.CharField(max_length=100, default='')
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) avatar = fields.ImageField(
following = models.ManyToManyField( upload_to='avatars/', blank=True, null=True, activitypub_field='icon')
followers = fields.ManyToManyField(
'self', 'self',
link_only=True,
symmetrical=False, symmetrical=False,
through='UserFollows', through='UserFollows',
through_fields=('user_subject', 'user_object'), through_fields=('user_object', 'user_subject'),
related_name='followers' related_name='following'
) )
follow_requests = models.ManyToManyField( follow_requests = models.ManyToManyField(
'self', 'self',
@ -66,60 +83,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
through_fields=('user', 'status'), through_fields=('user', 'status'),
related_name='favorite_statuses' related_name='favorite_statuses'
) )
remote_id = models.CharField(max_length=255, null=True, unique=True) remote_id = fields.RemoteIdField(
null=True, unique=True, activitypub_field='id')
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(auto_now=True) last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.BooleanField(default=False) manually_approves_followers = fields.BooleanField(default=False)
# ---- activitypub serialization settings for this model ----- #
@property
def ap_followers(self):
''' generates url for activitypub followers page '''
return '%s/followers' % self.remote_id
@property @property
def ap_public_key(self): def display_name(self):
''' format the public key block for activitypub ''' ''' show the cleanest version of the user's name possible '''
return activitypub.PublicKey(**{ if self.name != '':
'id': '%s/#main-key' % self.remote_id, return self.name
'owner': self.remote_id, return self.localname or self.username
'publicKeyPem': self.public_key,
})
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping(
'preferredUsername',
'username',
activity_formatter=lambda x: x.split('@')[0]
),
ActivityMapping('name', 'name'),
ActivityMapping('bookwyrmUser', 'bookwyrm_user'),
ActivityMapping('inbox', 'inbox'),
ActivityMapping('outbox', 'outbox'),
ActivityMapping('followers', 'ap_followers'),
ActivityMapping('summary', 'summary'),
ActivityMapping(
'publicKey',
'public_key',
model_formatter=lambda x: x.get('publicKeyPem')
),
ActivityMapping('publicKey', 'ap_public_key'),
ActivityMapping(
'endpoints',
'shared_inbox',
activity_formatter=lambda x: {'sharedInbox': x},
model_formatter=lambda x: x.get('sharedInbox')
),
ActivityMapping('icon', 'avatar'),
ActivityMapping(
'manuallyApprovesFollowers',
'manually_approves_followers'
),
# this field isn't in the activity but should always be false
ActivityMapping(None, 'local', model_formatter=lambda x: False),
]
activity_serializer = activitypub.Person activity_serializer = activitypub.Person
def to_outbox(self, **kwargs): def to_outbox(self, **kwargs):
@ -127,23 +104,23 @@ class User(OrderedCollectionPageMixin, AbstractUser):
queryset = Status.objects.filter( queryset = Status.objects.filter(
user=self, user=self,
deleted=False, deleted=False,
).select_subclasses() ).select_subclasses().order_by('-published_date')
return self.to_ordered_collection(queryset, \ return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs) remote_id=self.outbox, **kwargs)
def to_following_activity(self, **kwargs): def to_following_activity(self, **kwargs):
''' activitypub following list ''' ''' activitypub following list '''
remote_id = '%s/following' % self.remote_id remote_id = '%s/following' % self.remote_id
return self.to_ordered_collection(self.following, \ return self.to_ordered_collection(self.following.all(), \
remote_id=remote_id, id_only=True, **kwargs) remote_id=remote_id, id_only=True, **kwargs)
def to_followers_activity(self, **kwargs): def to_followers_activity(self, **kwargs):
''' activitypub followers list ''' ''' activitypub followers list '''
remote_id = '%s/followers' % self.remote_id remote_id = '%s/followers' % self.remote_id
return self.to_ordered_collection(self.followers, \ return self.to_ordered_collection(self.followers.all(), \
remote_id=remote_id, id_only=True, **kwargs) remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False): def to_activity(self):
''' override default AP serializer to add context object ''' override default AP serializer to add context object
idk if this is the best way to go about this ''' idk if this is the best way to go about this '''
activity_object = super().to_activity() activity_object = super().to_activity()
@ -180,18 +157,53 @@ class User(OrderedCollectionPageMixin, AbstractUser):
self.inbox = '%s/inbox' % self.remote_id self.inbox = '%s/inbox' % self.remote_id
self.shared_inbox = 'https://%s/inbox' % DOMAIN self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id self.outbox = '%s/outbox' % self.remote_id
if not self.private_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
class KeyPair(ActivitypubMixin, BookWyrmModel):
''' public and private keys for a user '''
private_key = models.TextField(blank=True, null=True)
public_key = fields.TextField(
blank=True, null=True, activitypub_field='publicKeyPem')
activity_serializer = activitypub.PublicKey
serialize_reverse_fields = [('owner', 'owner')]
def get_remote_id(self):
# self.owner is set by the OneToOneField on User
return '%s/#main-key' % self.owner.remote_id
def save(self, *args, **kwargs):
''' create a key pair '''
if not self.public_key:
self.private_key, self.public_key = create_key_pair()
return super().save(*args, **kwargs)
def to_activity(self):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
del activity_object['@context']
del activity_object['type']
return activity_object
@receiver(models.signals.post_save, sender=User) @receiver(models.signals.post_save, sender=User)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs): def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users ''' ''' create shelves for new users '''
if not instance.local or not created: if not created:
return return
if not instance.local:
set_remote_server.delay(instance.id)
return
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save()
shelves = [{ shelves = [{
'name': 'To Read', 'name': 'To Read',
'identifier': 'to-read', 'identifier': 'to-read',
@ -210,3 +222,54 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
user=instance, user=instance,
editable=False editable=False
).save() ).save()
@app.task
def set_remote_server(user_id):
''' figure out the user's remote server in the background '''
user = User.objects.get(id=user_id)
actor_parts = urlparse(user.remote_id)
user.federated_server = \
get_or_create_remote_server(actor_parts.netloc)
user.save()
if user.bookwyrm_user:
get_remote_reviews.delay(user.outbox)
def get_or_create_remote_server(domain):
''' get info on a remote server '''
try:
return FederatedServer.objects.get(
server_name=domain
)
except FederatedServer.DoesNotExist:
pass
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
data = get_data(nodeinfo_url)
server = FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
)
return server
@app.task
def get_remote_reviews(outbox):
''' ingest reviews by a new remote bookwyrm user '''
outbox_page = outbox + '?page=true'
data = get_data(outbox_page)
# TODO: pagination?
for activity in data['orderedItems']:
if not activity['type'] == 'Review':
continue
activitypub.Review(**activity).to_model(Review)

View file

@ -4,15 +4,15 @@ import re
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse from django.http import HttpResponseNotFound, JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import requests from requests import HTTPError
from bookwyrm import activitypub from bookwyrm import activitypub
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.broadcast import broadcast from bookwyrm.broadcast import broadcast
from bookwyrm.status import create_notification from bookwyrm.status import create_notification
from bookwyrm.status import create_generated_note from bookwyrm.status import create_generated_note
from bookwyrm.status import delete_status from bookwyrm.status import delete_status
from bookwyrm.remote_user import get_or_create_remote_user
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.utils import regex from bookwyrm.utils import regex
@ -54,16 +54,16 @@ def handle_remote_webfinger(query):
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \ url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \
(domain, query) (domain, query)
try: try:
response = requests.get(url) data = get_data(url)
except requests.exceptions.ConnectionError: except (ConnectorException, HTTPError):
return None return None
if not response.ok:
return None for link in data.get('links'):
data = response.json() if link.get('rel') == 'self':
for link in data['links']:
if link['rel'] == 'self':
try: try:
user = get_or_create_remote_user(link['href']) user = activitypub.resolve_remote_id(
models.User, link['href']
)
except KeyError: except KeyError:
return None return None
return user return user

View file

@ -1,111 +0,0 @@
''' manage remote users '''
from urllib.parse import urlparse
import requests
from django.db import transaction
from bookwyrm import activitypub, models
from bookwyrm import status as status_builder
from bookwyrm.tasks import app
def get_or_create_remote_user(actor):
''' look up a remote user or add them '''
try:
return models.User.objects.get(remote_id=actor)
except models.User.DoesNotExist:
pass
data = fetch_user_data(actor)
actor_parts = urlparse(actor)
with transaction.atomic():
user = activitypub.Person(**data).to_model(models.User)
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
user.save()
if user.bookwyrm_user:
get_remote_reviews.delay(user.id)
return user
def fetch_user_data(actor):
''' load the user's info from the actor url '''
try:
response = requests.get(
actor,
headers={'Accept': 'application/activity+json'}
)
except ConnectionError:
return None
if not response.ok:
response.raise_for_status()
data = response.json()
# make sure our actor is who they say they are
if actor != data['id']:
raise ValueError("Remote actor id must match url.")
return data
def refresh_remote_user(user):
''' get updated user data from its home instance '''
data = fetch_user_data(user.remote_id)
activity = activitypub.Person(**data)
activity.to_model(models.User, instance=user)
@app.task
def get_remote_reviews(user_id):
''' ingest reviews by a new remote bookwyrm user '''
try:
user = models.User.objects.get(id=user_id)
except models.User.DoesNotExist:
return
outbox_page = user.outbox + '?page=true'
response = requests.get(
outbox_page,
headers={'Accept': 'application/activity+json'}
)
data = response.json()
# TODO: pagination?
for activity in data['orderedItems']:
status_builder.create_status(activity)
def get_or_create_remote_server(domain):
''' get info on a remote server '''
try:
return models.FederatedServer.objects.get(
server_name=domain
)
except models.FederatedServer.DoesNotExist:
pass
response = requests.get(
'https://%s/.well-known/nodeinfo' % domain,
headers={'Accept': 'application/activity+json'}
)
if response.status_code != 200:
return None
data = response.json()
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
response = requests.get(
nodeinfo_url,
headers={'Accept': 'application/activity+json'}
)
data = response.json()
server = models.FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
)
return server

View file

@ -1,16 +0,0 @@
''' Routine tasks for keeping your library tidy '''
from datetime import timedelta
from django.utils import timezone
from bookwyrm import books_manager
from bookwyrm import models
def sync_book_data():
''' update books with any changes to their canonical source '''
expiry = timezone.now() - timedelta(days=1)
books = models.Edition.objects.filter(
sync=True,
last_sync_date__lte=expiry
).all()
for book in books:
# TODO: create background tasks
books_manager.update_book(book)

View file

@ -31,7 +31,7 @@ def make_signature(sender, destination, date, digest):
'digest: %s' % digest, 'digest: %s' % digest,
] ]
message_to_sign = '\n'.join(signature_headers) message_to_sign = '\n'.join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.private_key)) signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8'))) signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8')))
signature = { signature = {
'keyId': '%s#main-key' % sender.remote_id, 'keyId': '%s#main-key' % sender.remote_id,

View file

@ -12,37 +12,6 @@ def delete_status(status):
status.save() status.save()
def create_status(activity):
''' unfortunately, it's not QUITE as simple as deserializing it '''
# render the json into an activity object
serializer = activitypub.activity_objects[activity['type']]
activity = serializer(**activity)
try:
model = models.activity_models[activity.type]
except KeyError:
# not a type of status we are prepared to deserialize
return None
# ignore notes that aren't replies to known statuses
if activity.type == 'Note':
reply = models.Status.objects.filter(
remote_id=activity.inReplyTo
).first()
if not reply:
return None
# look up books
book_urls = []
if hasattr(activity, 'inReplyToBook'):
book_urls.append(activity.inReplyToBook)
if hasattr(activity, 'tag'):
book_urls += [t['href'] for t in activity.tag if t['type'] == 'Book']
for remote_id in book_urls:
books_manager.get_or_create_book(remote_id)
return activity.to_model(model)
def create_generated_note(user, content, mention_books=None, privacy='public'): def create_generated_note(user, content, mention_books=None, privacy='public'):
''' a note created by the app about user activity ''' ''' a note created by the app about user activity '''
# sanitize input html # sanitize input html

View file

@ -2,7 +2,7 @@
{% load fr_display %} {% load fr_display %}
{% block content %} {% block content %}
<div class="block"> <div class="block">
<h1 class="title">{{ author.display_name }}</h1> <h1 class="title">{{ author.name }}</h1>
{% if author.bio %} {% if author.bio %}
<p> <p>
@ -12,7 +12,7 @@
</div> </div>
<div class="block"> <div class="block">
<h3 class="title is-4">Books by {{ author.display_name }}</h3> <h3 class="title is-4">Books by {{ author.name }}</h3>
{% include 'snippets/book_tiles.html' with books=books %} {% include 'snippets/book_tiles.html' with books=books %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -86,8 +86,8 @@
{% endif %} {% endif %}
{% if book.parent_work.edition_set.count > 1 %} {% if book.parent_work.editions.count > 1 %}
<p><a href="/book/{{ book.parent_work.id }}/editions">{{ book.parent_work.edition_set.count }} editions</a></p> <p><a href="/book/{{ book.parent_work.id }}/editions">{{ book.parent_work.editions.count }} editions</a></p>
{% endif %} {% endif %}
</div> </div>

View file

@ -1 +1 @@
<a href="/author/{{ book.authors.first.id }}" class="author">{{ book.authors.first.display_name }}</a> <a href="/author/{{ book.authors.first.id }}" class="author">{{ book.authors.first.name }}</a>

View file

@ -43,7 +43,7 @@
<a href="/book/{{ book.id }}">{{ book.title }}</a> <a href="/book/{{ book.id }}">{{ book.title }}</a>
</td> </td>
<td> <td>
{{ book.authors.first.display_name }} {{ book.authors.first.name }}
</td> </td>
<td> <td>
{% if book.first_published_date %}{{ book.first_published_date }}{% endif %} {% if book.first_published_date %}{{ book.first_published_date }}{% endif %}

View file

@ -1,14 +1,14 @@
<div class="control"> <div class="control">
<form name="tag" action="/{% if tag.identifier in user_tags %}untag{% else %}tag{% endif %}/" method="post"> <form name="tag" action="/{% if tag.tag.identifier in user_tags %}untag{% else %}tag{% endif %}/" method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}"> <input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="name" value="{{ tag.name }}"> <input type="hidden" name="name" value="{{ tag.tag.name }}">
<div class="tags has-addons"> <div class="tags has-addons">
<a class="tag" href="/tag/{{ tag.identifier|urlencode }}"> <a class="tag" href="/tag/{{ tag.tag.identifier|urlencode }}">
{{ tag.name }} {{ tag.tag.name }}
</a> </a>
{% if tag.identifier in user_tags %} {% if tag.tag.identifier in user_tags %}
<button class="tag is-delete" type="submit"> <button class="tag is-delete" type="submit">
<span class="is-sr-only">remove tag</span> <span class="is-sr-only">remove tag</span>
</button> </button>

View file

@ -12,8 +12,6 @@ class Author(TestCase):
) )
self.author = models.Author.objects.create( self.author = models.Author.objects.create(
name='Author fullname', name='Author fullname',
first_name='Auth',
last_name='Or',
aliases=['One', 'Two'], aliases=['One', 'Two'],
bio='bio bio bio', bio='bio bio bio',
) )

View file

@ -0,0 +1,215 @@
''' tests the base functionality for activitypub dataclasses '''
from io import BytesIO
import json
import pathlib
from unittest.mock import patch
from dataclasses import dataclass
from django.test import TestCase
from PIL import Image
import responses
from bookwyrm import activitypub
from bookwyrm.activitypub.base_activity import ActivityObject, \
resolve_remote_id, set_related_field
from bookwyrm.activitypub import ActivitySerializerError
from bookwyrm import models
class BaseActivity(TestCase):
''' the super class for model-linked activitypub dataclasses '''
def setUp(self):
''' we're probably going to re-use this so why copy/paste '''
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
self.user.remote_id = 'http://example.com/a/b'
self.user.save()
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
self.userdata = json.loads(datafile.read_bytes())
# don't try to load the user icon
del self.userdata['icon']
image_file = pathlib.Path(__file__).parent.joinpath(
'../../static/images/default_avi.jpg')
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
self.image_data = output.getvalue()
def test_init(self):
''' simple successfuly init '''
instance = ActivityObject(id='a', type='b')
self.assertTrue(hasattr(instance, 'id'))
self.assertTrue(hasattr(instance, 'type'))
def test_init_missing(self):
''' init with missing required params '''
with self.assertRaises(ActivitySerializerError):
ActivityObject()
def test_init_extra_fields(self):
''' init ignoring additional fields '''
instance = ActivityObject(id='a', type='b', fish='c')
self.assertTrue(hasattr(instance, 'id'))
self.assertTrue(hasattr(instance, 'type'))
def test_init_default_field(self):
''' replace an existing required field with a default field '''
@dataclass(init=False)
class TestClass(ActivityObject):
''' test class with default field '''
type: str = 'TestObject'
instance = TestClass(id='a')
self.assertEqual(instance.id, 'a')
self.assertEqual(instance.type, 'TestObject')
def test_serialize(self):
''' simple function for converting dataclass to dict '''
instance = ActivityObject(id='a', type='b')
serialized = instance.serialize()
self.assertIsInstance(serialized, dict)
self.assertEqual(serialized['id'], 'a')
self.assertEqual(serialized['type'], 'b')
@responses.activate
def test_resolve_remote_id(self):
''' look up or load remote data '''
# existing item
result = resolve_remote_id(models.User, 'http://example.com/a/b')
self.assertEqual(result, self.user)
# remote item
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=self.userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
result = resolve_remote_id(
models.User, 'https://example.com/user/mouse')
self.assertIsInstance(result, models.User)
self.assertEqual(result.remote_id, 'https://example.com/user/mouse')
self.assertEqual(result.name, 'MOUSE?? MOUSE!!')
def test_to_model(self):
''' the big boy of this module. it feels janky to test this with actual
models rather than a test model, but I don't know how to make a test
model so here we are. '''
instance = ActivityObject(id='a', type='b')
with self.assertRaises(ActivitySerializerError):
instance.to_model(models.User)
# test setting simple fields
self.assertEqual(self.user.name, '')
update_data = activitypub.Person(**self.user.to_activity())
update_data.name = 'New Name'
update_data.to_model(models.User, self.user)
self.assertEqual(self.user.name, 'New Name')
def test_to_model_foreign_key(self):
''' test setting one to one/foreign key '''
update_data = activitypub.Person(**self.user.to_activity())
update_data.publicKey['publicKeyPem'] = 'hi im secure'
update_data.to_model(models.User, self.user)
self.assertEqual(self.user.key_pair.public_key, 'hi im secure')
@responses.activate
def test_to_model_image(self):
''' update an image field '''
update_data = activitypub.Person(**self.user.to_activity())
update_data.icon = {'url': 'http://www.example.com/image.jpg'}
responses.add(
responses.GET,
'http://www.example.com/image.jpg',
body=self.image_data,
status=200)
self.assertIsNone(self.user.avatar.name)
with self.assertRaises(ValueError):
self.user.avatar.file #pylint: disable=pointless-statement
update_data.to_model(models.User, self.user)
self.assertIsNotNone(self.user.avatar.name)
self.assertIsNotNone(self.user.avatar.file)
def test_to_model_many_to_many(self):
''' annoying that these all need special handling '''
status = models.Status.objects.create(
content='test status',
user=self.user,
)
book = models.Edition.objects.create(
title='Test Edition', remote_id='http://book.com/book')
update_data = activitypub.Note(**status.to_activity())
update_data.tag = [
{
'type': 'Mention',
'name': 'gerald',
'href': 'http://example.com/a/b'
},
{
'type': 'Edition',
'name': 'gerald j. books',
'href': 'http://book.com/book'
},
]
update_data.to_model(models.Status, instance=status)
self.assertEqual(status.mention_users.first(), self.user)
self.assertEqual(status.mention_books.first(), book)
@responses.activate
def test_to_model_one_to_many(self):
''' these are reversed relationships, where the secondary object
keys the primary object but not vice versa '''
status = models.Status.objects.create(
content='test status',
user=self.user,
)
update_data = activitypub.Note(**status.to_activity())
update_data.attachment = [{
'url': 'http://www.example.com/image.jpg',
'name': 'alt text',
'type': 'Image',
}]
responses.add(
responses.GET,
'http://www.example.com/image.jpg',
body=self.image_data,
status=200)
# sets the celery task call to the function call
with patch(
'bookwyrm.activitypub.base_activity.set_related_field.delay'):
update_data.to_model(models.Status, instance=status)
self.assertIsNone(status.attachments.first())
@responses.activate
def test_set_related_field(self):
''' celery task to add back-references to created objects '''
status = models.Status.objects.create(
content='test status',
user=self.user,
)
data = {
'url': 'http://www.example.com/image.jpg',
'name': 'alt text',
'type': 'Image',
}
responses.add(
responses.GET,
'http://www.example.com/image.jpg',
body=self.image_data,
status=200)
set_related_field(
'Image', 'Status', 'status', status.remote_id, data)
self.assertIsInstance(status.attachments.first(), models.Image)
self.assertIsNotNone(status.attachments.first().image)

View file

@ -1,5 +1,7 @@
# pylint: disable=missing-module-docstring, missing-class-docstring, missing-function-docstring
import json import json
import pathlib import pathlib
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
@ -7,9 +9,6 @@ from bookwyrm import activitypub, models
class Person(TestCase): class Person(TestCase):
def setUp(self): def setUp(self):
self.user = models.User.objects.create_user(
'rat', 'rat@rat.com', 'ratword',
)
datafile = pathlib.Path(__file__).parent.joinpath( datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json' '../data/ap_user.json'
) )
@ -21,3 +20,12 @@ class Person(TestCase):
self.assertEqual(activity.id, 'https://example.com/user/mouse') self.assertEqual(activity.id, 'https://example.com/user/mouse')
self.assertEqual(activity.preferredUsername, 'mouse') self.assertEqual(activity.preferredUsername, 'mouse')
self.assertEqual(activity.type, 'Person') self.assertEqual(activity.type, 'Person')
def test_user_to_model(self):
activity = activitypub.Person(**self.user_data)
with patch('bookwyrm.models.user.set_remote_server.delay'):
user = activity.to_model(models.User)
self.assertEqual(user.username, 'mouse@example.com')
self.assertEqual(user.remote_id, 'https://example.com/user/mouse')
self.assertFalse(user.local)

View file

@ -1,5 +1,7 @@
''' quotation activty object serializer class '''
import json import json
import pathlib import pathlib
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
@ -8,13 +10,15 @@ from bookwyrm import activitypub, models
class Quotation(TestCase): class Quotation(TestCase):
''' we have hecka ways to create statuses ''' ''' we have hecka ways to create statuses '''
def setUp(self): def setUp(self):
self.user = models.User.objects.create_user( ''' model objects we'll need '''
'mouse', 'mouse@mouse.mouse', 'mouseword', with patch('bookwyrm.models.user.set_remote_server.delay'):
local=False, self.user = models.User.objects.create_user(
inbox='https://example.com/user/mouse/inbox', 'mouse', 'mouse@mouse.mouse', 'mouseword',
outbox='https://example.com/user/mouse/outbox', local=False,
remote_id='https://example.com/user/mouse', inbox='https://example.com/user/mouse/inbox',
) outbox='https://example.com/user/mouse/outbox',
remote_id='https://example.com/user/mouse',
)
self.book = models.Edition.objects.create( self.book = models.Edition.objects.create(
title='Example Edition', title='Example Edition',
remote_id='https://example.com/book/1', remote_id='https://example.com/book/1',
@ -26,6 +30,7 @@ class Quotation(TestCase):
def test_quotation_activity(self): def test_quotation_activity(self):
''' create a Quoteation ap object from json '''
quotation = activitypub.Quotation(**self.status_data) quotation = activitypub.Quotation(**self.status_data)
self.assertEqual(quotation.type, 'Quotation') self.assertEqual(quotation.type, 'Quotation')
@ -39,6 +44,7 @@ class Quotation(TestCase):
def test_activity_to_model(self): def test_activity_to_model(self):
''' create a model instance from an activity object '''
activity = activitypub.Quotation(**self.status_data) activity = activitypub.Quotation(**self.status_data)
quotation = activity.to_model(models.Quotation) quotation = activity.to_model(models.Quotation)

View file

@ -3,7 +3,7 @@ from django.test import TestCase
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors.abstract_connector import Mapping from bookwyrm.connectors.abstract_connector import Mapping
from bookwyrm.connectors.bookwyrm_connector import Connector from bookwyrm.connectors.openlibrary import Connector
class AbstractConnector(TestCase): class AbstractConnector(TestCase):
@ -12,7 +12,7 @@ class AbstractConnector(TestCase):
models.Connector.objects.create( models.Connector.objects.create(
identifier='example.com', identifier='example.com',
connector_file='bookwyrm_connector', connector_file='openlibrary',
base_url='https://example.com', base_url='https://example.com',
books_url='https:/example.com', books_url='https:/example.com',
covers_url='https://example.com', covers_url='https://example.com',

View file

@ -1,16 +1,17 @@
''' testing book data connectors ''' ''' testing book data connectors '''
from dateutil import parser
from django.test import TestCase
import json import json
import pathlib import pathlib
from django.test import TestCase
from bookwyrm import models from bookwyrm import models
from bookwyrm.connectors.bookwyrm_connector import Connector from bookwyrm.connectors.bookwyrm_connector import Connector
from bookwyrm.connectors.abstract_connector import SearchResult, get_date from bookwyrm.connectors.abstract_connector import SearchResult
class BookWyrmConnector(TestCase): class BookWyrmConnector(TestCase):
''' this connector doesn't do much, just search '''
def setUp(self): def setUp(self):
''' create the connector '''
models.Connector.objects.create( models.Connector.objects.create(
identifier='example.com', identifier='example.com',
connector_file='bookwyrm_connector', connector_file='bookwyrm_connector',
@ -29,13 +30,9 @@ class BookWyrmConnector(TestCase):
self.edition_data = json.loads(edition_file.read_bytes()) self.edition_data = json.loads(edition_file.read_bytes())
def test_is_work_data(self):
self.assertEqual(self.connector.is_work_data(self.work_data), True)
self.assertEqual(self.connector.is_work_data(self.edition_data), False)
def test_format_search_result(self): def test_format_search_result(self):
datafile = pathlib.Path(__file__).parent.joinpath('../data/fr_search.json') datafile = pathlib.Path(__file__).parent.joinpath(
'../data/fr_search.json')
search_data = json.loads(datafile.read_bytes()) search_data = json.loads(datafile.read_bytes())
results = self.connector.parse_search_data(search_data) results = self.connector.parse_search_data(search_data)
self.assertIsInstance(results, list) self.assertIsInstance(results, list)
@ -46,9 +43,3 @@ class BookWyrmConnector(TestCase):
self.assertEqual(result.key, 'https://example.com/book/122') self.assertEqual(result.key, 'https://example.com/book/122')
self.assertEqual(result.author, 'Susanna Clarke') self.assertEqual(result.author, 'Susanna Clarke')
self.assertEqual(result.year, 2017) self.assertEqual(result.year, 2017)
def test_get_date(self):
date = get_date(self.edition_data['published_date'])
expected = parser.parse("2020-09-15T00:00:00+00:00")
self.assertEqual(date, expected)

View file

@ -1,5 +1,6 @@
import json import json
import pathlib import pathlib
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, incoming from bookwyrm import models, incoming
@ -7,15 +8,17 @@ from bookwyrm import models, incoming
class Favorite(TestCase): class Favorite(TestCase):
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( with patch('bookwyrm.models.user.set_remote_server.delay'):
'rat', 'rat@rat.com', 'ratword', with patch('bookwyrm.models.user.get_remote_reviews.delay'):
local=False, self.remote_user = models.User.objects.create_user(
remote_id='https://example.com/users/rat', 'rat', 'rat@rat.com', 'ratword',
inbox='https://example.com/users/rat/inbox', local=False,
outbox='https://example.com/users/rat/outbox', remote_id='https://example.com/users/rat',
) inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', 'mouse', 'mouse@mouse.com', 'mouseword', local=True,
remote_id='http://local.com/user/mouse') remote_id='http://local.com/user/mouse')
self.status = models.Status.objects.create( self.status = models.Status.objects.create(

View file

@ -6,15 +6,17 @@ from bookwyrm import models, incoming
class IncomingFollow(TestCase): class IncomingFollow(TestCase):
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( with patch('bookwyrm.models.user.set_remote_server.delay'):
'rat', 'rat@rat.com', 'ratword', with patch('bookwyrm.models.user.get_remote_reviews.delay'):
local=False, self.remote_user = models.User.objects.create_user(
remote_id='https://example.com/users/rat', 'rat', 'rat@rat.com', 'ratword',
inbox='https://example.com/users/rat/inbox', local=False,
outbox='https://example.com/users/rat/outbox', remote_id='https://example.com/users/rat',
) inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword') 'mouse', 'mouse@mouse.com', 'mouseword', local=True)
self.local_user.remote_id = 'http://local.com/user/mouse' self.local_user.remote_id = 'http://local.com/user/mouse'
self.local_user.save() self.local_user.save()
@ -73,24 +75,3 @@ class IncomingFollow(TestCase):
# the follow relationship should not exist # the follow relationship should not exist
follow = models.UserFollows.objects.all() follow = models.UserFollows.objects.all()
self.assertEqual(list(follow), []) self.assertEqual(list(follow), [])
def test_nonexistent_user_follow(self):
activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://example.com/users/rat/follows/123",
"type": "Follow",
"actor": "https://example.com/users/rat",
"object": "http://local.com/user/nonexistent-user"
}
with patch('bookwyrm.broadcast.broadcast_task.delay') as _:
incoming.handle_follow(activity)
# do nothing
notifications = models.Notification.objects.all()
self.assertEqual(list(notifications), [])
requests = models.UserFollowRequest.objects.all()
self.assertEqual(list(requests), [])
follows = models.UserFollows.objects.all()
self.assertEqual(list(follows), [])

View file

@ -1,3 +1,4 @@
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, incoming from bookwyrm import models, incoming
@ -5,15 +6,17 @@ from bookwyrm import models, incoming
class IncomingFollowAccept(TestCase): class IncomingFollowAccept(TestCase):
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( with patch('bookwyrm.models.user.set_remote_server.delay'):
'rat', 'rat@rat.com', 'ratword', with patch('bookwyrm.models.user.get_remote_reviews.delay'):
local=False, self.remote_user = models.User.objects.create_user(
remote_id='https://example.com/users/rat', 'rat', 'rat@rat.com', 'ratword',
inbox='https://example.com/users/rat/inbox', local=False,
outbox='https://example.com/users/rat/outbox', remote_id='https://example.com/users/rat',
) inbox='https://example.com/users/rat/inbox',
outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword') 'mouse', 'mouse@mouse.com', 'mouseword', local=True)
self.local_user.remote_id = 'http://local.com/user/mouse' self.local_user.remote_id = 'http://local.com/user/mouse'
self.local_user.save() self.local_user.save()

View file

@ -1,31 +0,0 @@
''' when a remote user changes their profile '''
import json
import pathlib
from django.test import TestCase
from bookwyrm import models, incoming
class UpdateUser(TestCase):
def setUp(self):
self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword',
remote_id='https://example.com/user/mouse',
local=False,
localname='mouse'
)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_handle_update_user(self):
self.assertIsNone(self.user.name)
self.assertEqual(self.user.localname, 'mouse')
incoming.handle_update_user({'object': self.user_data})
self.user = models.User.objects.get(id=self.user.id)
self.assertEqual(self.user.name, 'MOUSE?? MOUSE!!')
self.assertEqual(self.user.localname, 'mouse')

View file

@ -1,25 +1,202 @@
''' testing models ''' ''' testing models '''
from collections import namedtuple
from dataclasses import dataclass
import re
from django.test import TestCase from django.test import TestCase
from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm import models from bookwyrm import models
from bookwyrm.models.base_model import BookWyrmModel from bookwyrm.models import base_model
from bookwyrm.models.base_model import ActivitypubMixin
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
class BaseModel(TestCase): class BaseModel(TestCase):
''' functionality shared across models '''
def test_remote_id(self): def test_remote_id(self):
instance = BookWyrmModel() ''' these should be generated '''
instance = base_model.BookWyrmModel()
instance.id = 1 instance.id = 1
expected = instance.get_remote_id() expected = instance.get_remote_id()
self.assertEqual(expected, 'https://%s/bookwyrmmodel/1' % DOMAIN) self.assertEqual(expected, 'https://%s/bookwyrmmodel/1' % DOMAIN)
def test_remote_id_with_user(self): def test_remote_id_with_user(self):
''' format of remote id when there's a user object '''
user = models.User.objects.create_user( user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword') 'mouse', 'mouse@mouse.com', 'mouseword', local=True)
instance = BookWyrmModel() instance = base_model.BookWyrmModel()
instance.user = user instance.user = user
instance.id = 1 instance.id = 1
expected = instance.get_remote_id() expected = instance.get_remote_id()
self.assertEqual( self.assertEqual(
expected, expected,
'https://%s/user/mouse/bookwyrmmodel/1' % DOMAIN) 'https://%s/user/mouse/bookwyrmmodel/1' % DOMAIN)
def test_execute_after_save(self):
''' this function sets remote ids after creation '''
# using Work because it BookWrymModel is abstract and this requires save
# Work is a relatively not-fancy model.
instance = models.Work.objects.create(title='work title')
instance.remote_id = None
base_model.execute_after_save(None, instance, True)
self.assertEqual(
instance.remote_id,
'https://%s/book/%d' % (DOMAIN, instance.id)
)
# shouldn't set remote_id if it's not created
instance.remote_id = None
base_model.execute_after_save(None, instance, False)
self.assertIsNone(instance.remote_id)
def test_to_create_activity(self):
''' wrapper for ActivityPub "create" action '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', local=True)
object_activity = {
'to': 'to field', 'cc': 'cc field',
'content': 'hi',
'published': '2020-12-04T17:52:22.623807+00:00',
}
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: object_activity
)
activity = ActivitypubMixin.to_create_activity(mock_self, user)
self.assertEqual(
activity['id'],
'https://example.com/status/1/activity'
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Create')
self.assertEqual(activity['to'], 'to field')
self.assertEqual(activity['cc'], 'cc field')
self.assertEqual(activity['object'], object_activity)
self.assertEqual(
activity['signature'].creator,
'%s#main-key' % user.remote_id
)
def test_to_delete_activity(self):
''' wrapper for Delete activity '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', local=True)
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ActivitypubMixin.to_delete_activity(mock_self, user)
self.assertEqual(
activity['id'],
'https://example.com/status/1/activity'
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Delete')
self.assertEqual(
activity['to'],
['%s/followers' % user.remote_id])
self.assertEqual(
activity['cc'],
['https://www.w3.org/ns/activitystreams#Public'])
def test_to_update_activity(self):
''' ditto above but for Update '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', local=True)
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ActivitypubMixin.to_update_activity(mock_self, user)
self.assertIsNotNone(
re.match(
r'^https:\/\/example\.com\/status\/1#update\/.*',
activity['id']
)
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Update')
self.assertEqual(
activity['to'],
['https://www.w3.org/ns/activitystreams#Public'])
self.assertEqual(activity['object'], {})
def test_to_undo_activity(self):
''' and again, for Undo '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', local=True)
MockSelf = namedtuple('Self', ('remote_id', 'to_activity'))
mock_self = MockSelf(
'https://example.com/status/1',
lambda *args: {}
)
activity = ActivitypubMixin.to_undo_activity(mock_self, user)
self.assertEqual(
activity['id'],
'https://example.com/status/1#undo'
)
self.assertEqual(activity['actor'], user.remote_id)
self.assertEqual(activity['type'], 'Undo')
self.assertEqual(activity['object'], {})
def test_to_activity(self):
''' model to ActivityPub json '''
@dataclass(init=False)
class TestActivity(ActivityObject):
''' real simple mock '''
type: str = 'Test'
class TestModel(ActivitypubMixin, base_model.BookWyrmModel):
''' real simple mock model because BookWyrmModel is abstract '''
instance = TestModel()
instance.remote_id = 'https://www.example.com/test'
instance.activity_serializer = TestActivity
activity = instance.to_activity()
self.assertIsInstance(activity, dict)
self.assertEqual(activity['id'], 'https://www.example.com/test')
self.assertEqual(activity['type'], 'Test')
def test_find_existing_by_remote_id(self):
''' attempt to match a remote id to an object in the db '''
# uses a different remote id scheme
# this isn't really part of this test directly but it's helpful to state
book = models.Edition.objects.create(
title='Test Edition', remote_id='http://book.com/book')
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
user.remote_id = 'http://example.com/a/b'
user.save()
self.assertEqual(book.origin_id, 'http://book.com/book')
self.assertNotEqual(book.remote_id, 'http://book.com/book')
# uses subclasses
models.Comment.objects.create(
user=user, content='test status', book=book, \
remote_id='https://comment.net')
result = models.User.find_existing_by_remote_id('hi')
self.assertIsNone(result)
result = models.User.find_existing_by_remote_id(
'http://example.com/a/b')
self.assertEqual(result, user)
# test using origin id
result = models.Edition.find_existing_by_remote_id(
'http://book.com/book')
self.assertEqual(result, book)
# test subclass match
result = models.Status.find_existing_by_remote_id(
'https://comment.net')

View file

@ -24,7 +24,7 @@ class Book(TestCase):
def test_remote_id(self): def test_remote_id(self):
remote_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id) remote_id = 'https://%s/book/%d' % (settings.DOMAIN, self.work.id)
self.assertEqual(self.work.get_remote_id(), remote_id) self.assertEqual(self.work.get_remote_id(), remote_id)
self.assertEqual(self.work.remote_id, 'https://example.com/book/1') self.assertEqual(self.work.remote_id, remote_id)
def test_create_book(self): def test_create_book(self):
''' you shouldn't be able to create Books (only editions and works) ''' ''' you shouldn't be able to create Books (only editions and works) '''
@ -59,7 +59,7 @@ class Book(TestCase):
class Shelf(TestCase): class Shelf(TestCase):
def setUp(self): def setUp(self):
user = models.User.objects.create_user( user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword') 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
models.Shelf.objects.create( models.Shelf.objects.create(
name='Test Shelf', identifier='test-shelf', user=user) name='Test Shelf', identifier='test-shelf', user=user)

View file

@ -0,0 +1,314 @@
''' testing models '''
from io import BytesIO
from collections import namedtuple
import json
import pathlib
import re
from unittest.mock import patch
from PIL import Image
import responses
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.test import TestCase
from django.utils import timezone
from bookwyrm.models import fields, User
class ActivitypubFields(TestCase):
''' overwrites standard model feilds to work with activitypub '''
def test_validate_remote_id(self):
''' should look like a url '''
self.assertIsNone(fields.validate_remote_id(
'http://www.example.com'
))
self.assertIsNone(fields.validate_remote_id(
'https://www.example.com'
))
self.assertIsNone(fields.validate_remote_id(
'http://example.com/dlfjg-23/x'
))
self.assertRaises(
ValidationError, fields.validate_remote_id,
'http:/example.com/dlfjg-23/x'
)
self.assertRaises(
ValidationError, fields.validate_remote_id,
'www.example.com/dlfjg-23/x'
)
self.assertRaises(
ValidationError, fields.validate_remote_id,
'http://www.example.com/dlfjg 23/x'
)
def test_activitypub_field_mixin(self):
''' generic mixin with super basic to and from functionality '''
instance = fields.ActivitypubFieldMixin()
self.assertEqual(instance.field_to_activity('fish'), 'fish')
self.assertEqual(instance.field_from_activity('fish'), 'fish')
self.assertFalse(instance.deduplication_field)
instance = fields.ActivitypubFieldMixin(
activitypub_wrapper='endpoints', activitypub_field='outbox'
)
self.assertEqual(
instance.field_to_activity('fish'),
{'outbox': 'fish'}
)
self.assertEqual(
instance.field_from_activity({'outbox': 'fish'}),
'fish'
)
self.assertEqual(instance.get_activitypub_field(), 'endpoints')
instance = fields.ActivitypubFieldMixin()
instance.name = 'snake_case_name'
self.assertEqual(instance.get_activitypub_field(), 'snakeCaseName')
def test_remote_id_field(self):
''' just sets some defaults on charfield '''
instance = fields.RemoteIdField()
self.assertEqual(instance.max_length, 255)
self.assertTrue(instance.deduplication_field)
with self.assertRaises(ValidationError):
instance.run_validators('http://www.example.com/dlfjg 23/x')
def test_username_field(self):
''' again, just setting defaults on username field '''
instance = fields.UsernameField()
self.assertEqual(instance.activitypub_field, 'preferredUsername')
self.assertEqual(instance.max_length, 150)
self.assertEqual(instance.unique, True)
with self.assertRaises(ValidationError):
instance.run_validators('one two')
instance.run_validators('a*&')
instance.run_validators('trailingwhite ')
self.assertIsNone(instance.run_validators('aksdhf'))
self.assertEqual(instance.field_to_activity('test@example.com'), 'test')
def test_foreign_key(self):
''' should be able to format a related model '''
instance = fields.ForeignKey('User', on_delete=models.CASCADE)
Serializable = namedtuple('Serializable', ('to_activity', 'remote_id'))
item = Serializable(lambda: {'a': 'b'}, 'https://e.b/c')
# returns the remote_id field of the related object
self.assertEqual(instance.field_to_activity(item), 'https://e.b/c')
@responses.activate
def test_foreign_key_from_activity_str(self):
''' create a new object from a foreign key '''
instance = fields.ForeignKey(User, on_delete=models.CASCADE)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json')
userdata = json.loads(datafile.read_bytes())
# don't try to load the user icon
del userdata['icon']
# it shouldn't match with this unrelated user:
unrelated_user = User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword', local=True)
# test receiving an unknown remote id and loading data
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
value = instance.field_from_activity(
'https://example.com/user/mouse')
self.assertIsInstance(value, User)
self.assertNotEqual(value, unrelated_user)
self.assertEqual(value.remote_id, 'https://example.com/user/mouse')
self.assertEqual(value.name, 'MOUSE?? MOUSE!!')
def test_foreign_key_from_activity_dict(self):
''' test recieving activity json '''
instance = fields.ForeignKey(User, on_delete=models.CASCADE)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json')
userdata = json.loads(datafile.read_bytes())
# don't try to load the user icon
del userdata['icon']
# it shouldn't match with this unrelated user:
unrelated_user = User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword', local=True)
with patch('bookwyrm.models.user.set_remote_server.delay'):
value = instance.field_from_activity(userdata)
self.assertIsInstance(value, User)
self.assertNotEqual(value, unrelated_user)
self.assertEqual(value.remote_id, 'https://example.com/user/mouse')
self.assertEqual(value.name, 'MOUSE?? MOUSE!!')
# et cetera but we're not testing serializing user json
def test_foreign_key_from_activity_dict_existing(self):
''' test receiving a dict of an existing object in the db '''
instance = fields.ForeignKey(User, on_delete=models.CASCADE)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
userdata = json.loads(datafile.read_bytes())
user = User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
user.remote_id = 'https://example.com/user/mouse'
user.save()
User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword', local=True)
value = instance.field_from_activity(userdata)
self.assertEqual(value, user)
def test_foreign_key_from_activity_str_existing(self):
''' test receiving a remote id of an existing object in the db '''
instance = fields.ForeignKey(User, on_delete=models.CASCADE)
user = User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword', local=True)
value = instance.field_from_activity(user.remote_id)
self.assertEqual(value, user)
def test_one_to_one_field(self):
''' a gussied up foreign key '''
instance = fields.OneToOneField('User', on_delete=models.CASCADE)
Serializable = namedtuple('Serializable', ('to_activity', 'remote_id'))
item = Serializable(lambda: {'a': 'b'}, 'https://e.b/c')
self.assertEqual(instance.field_to_activity(item), {'a': 'b'})
def test_many_to_many_field(self):
''' lists! '''
instance = fields.ManyToManyField('User')
Serializable = namedtuple('Serializable', ('to_activity', 'remote_id'))
Queryset = namedtuple('Queryset', ('all', 'instance'))
item = Serializable(lambda: {'a': 'b'}, 'https://e.b/c')
another_item = Serializable(lambda: {}, 'example.com')
items = Queryset(lambda: [item], another_item)
self.assertEqual(instance.field_to_activity(items), ['https://e.b/c'])
instance = fields.ManyToManyField('User', link_only=True)
instance.name = 'snake_case'
self.assertEqual(
instance.field_to_activity(items),
'example.com/snake_case'
)
@responses.activate
def test_many_to_many_field_from_activity(self):
''' resolve related fields for a list, takes a list of remote ids '''
instance = fields.ManyToManyField(User)
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
userdata = json.loads(datafile.read_bytes())
# don't try to load the user icon
del userdata['icon']
# test receiving an unknown remote id and loading data
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
value = instance.field_from_activity(
['https://example.com/user/mouse', 'bleh']
)
self.assertIsInstance(value, list)
self.assertEqual(len(value), 1)
self.assertIsInstance(value[0], User)
def test_tag_field(self):
''' a special type of many to many field '''
instance = fields.TagField('User')
Serializable = namedtuple(
'Serializable',
('to_activity', 'remote_id', 'name_field', 'name')
)
Queryset = namedtuple('Queryset', ('all', 'instance'))
item = Serializable(
lambda: {'a': 'b'}, 'https://e.b/c', 'name', 'Name')
another_item = Serializable(
lambda: {}, 'example.com', '', '')
items = Queryset(lambda: [item], another_item)
result = instance.field_to_activity(items)
self.assertIsInstance(result, list)
self.assertEqual(len(result), 1)
self.assertEqual(result[0].href, 'https://e.b/c')
self.assertEqual(result[0].name, 'Name')
self.assertEqual(result[0].type, 'Serializable')
def test_tag_field_from_activity(self):
''' loadin' a list of items from Links '''
# TODO
@responses.activate
def test_image_field(self):
''' storing images '''
user = User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
image_file = pathlib.Path(__file__).parent.joinpath(
'../../static/images/default_avi.jpg')
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
user.avatar.save(
'test.jpg',
ContentFile(output.getvalue())
)
output = fields.image_serializer(user.avatar)
self.assertIsNotNone(
re.match(
r'.*\.jpg',
output.url,
)
)
self.assertEqual(output.type, 'Image')
instance = fields.ImageField()
self.assertEqual(instance.field_to_activity(user.avatar), output)
responses.add(
responses.GET,
'http://www.example.com/image.jpg',
body=user.avatar.file.read(),
status=200)
loaded_image = instance.field_from_activity(
'http://www.example.com/image.jpg')
self.assertIsInstance(loaded_image, list)
self.assertIsInstance(loaded_image[1], ContentFile)
def test_datetime_field(self):
''' this one is pretty simple, it just has to use isoformat '''
instance = fields.DateTimeField()
now = timezone.now()
self.assertEqual(instance.field_to_activity(now), now.isoformat())
self.assertEqual(
instance.field_from_activity(now.isoformat()), now
)
self.assertEqual(instance.field_from_activity('bip'), None)
def test_array_field(self):
''' idk why it makes them strings but probably for a good reason '''
instance = fields.ArrayField(fields.IntegerField)
self.assertEqual(instance.field_to_activity([0, 1]), ['0', '1'])

View file

@ -52,7 +52,7 @@ class ImportJob(TestCase):
unknown_read_data['Date Read'] = '' unknown_read_data['Date Read'] = ''
user = models.User.objects.create_user( user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword') 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
job = models.ImportJob.objects.create(user=user) job = models.ImportJob.objects.create(user=user)
models.ImportItem.objects.create( models.ImportItem.objects.create(
job=job, index=1, data=currently_reading_data) job=job, index=1, data=currently_reading_data)

View file

@ -1,4 +1,5 @@
''' testing models ''' ''' testing models '''
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models from bookwyrm import models
@ -6,15 +7,16 @@ from bookwyrm import models
class Relationship(TestCase): class Relationship(TestCase):
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( with patch('bookwyrm.models.user.set_remote_server.delay'):
'rat', 'rat@rat.com', 'ratword', self.remote_user = models.User.objects.create_user(
local=False, 'rat', 'rat@rat.com', 'ratword',
remote_id='https://example.com/users/rat', local=False,
inbox='https://example.com/users/rat/inbox', remote_id='https://example.com/users/rat',
outbox='https://example.com/users/rat/outbox', inbox='https://example.com/users/rat/inbox',
) outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword') 'mouse', 'mouse@mouse.com', 'mouseword', local=True)
self.local_user.remote_id = 'http://local.com/user/mouse' self.local_user.remote_id = 'http://local.com/user/mouse'
self.local_user.save() self.local_user.save()

View file

@ -7,7 +7,7 @@ from bookwyrm import models, settings
class Status(TestCase): class Status(TestCase):
def setUp(self): def setUp(self):
user = models.User.objects.create_user( user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword') 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
book = models.Edition.objects.create(title='Example Edition') book = models.Edition.objects.create(title='Example Edition')
models.Status.objects.create(user=user, content='Blah blah') models.Status.objects.create(user=user, content='Blah blah')
@ -40,13 +40,3 @@ class Status(TestCase):
expected_id = 'https://%s/user/mouse/review/%d' % \ expected_id = 'https://%s/user/mouse/review/%d' % \
(settings.DOMAIN, review.id) (settings.DOMAIN, review.id)
self.assertEqual(review.remote_id, expected_id) self.assertEqual(review.remote_id, expected_id)
class Tag(TestCase):
def test_tag(self):
book = models.Edition.objects.create(title='Example Edition')
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword')
tag = models.Tag.objects.create(user=user, book=book, name='t/est tag')
self.assertEqual(tag.identifier, 't%2Fest+tag')

View file

@ -1,4 +1,5 @@
''' testing models ''' ''' testing models '''
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models from bookwyrm import models
@ -8,7 +9,7 @@ from bookwyrm.settings import DOMAIN
class User(TestCase): class User(TestCase):
def setUp(self): def setUp(self):
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword') 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
def test_computed_fields(self): def test_computed_fields(self):
''' username instead of id here ''' ''' username instead of id here '''
@ -19,8 +20,15 @@ class User(TestCase):
self.assertEqual(self.user.shared_inbox, 'https://%s/inbox' % DOMAIN) self.assertEqual(self.user.shared_inbox, 'https://%s/inbox' % DOMAIN)
self.assertEqual(self.user.inbox, '%s/inbox' % expected_id) self.assertEqual(self.user.inbox, '%s/inbox' % expected_id)
self.assertEqual(self.user.outbox, '%s/outbox' % expected_id) self.assertEqual(self.user.outbox, '%s/outbox' % expected_id)
self.assertIsNotNone(self.user.private_key) self.assertIsNotNone(self.user.key_pair.private_key)
self.assertIsNotNone(self.user.public_key) self.assertIsNotNone(self.user.key_pair.public_key)
def test_remote_user(self):
with patch('bookwyrm.models.user.set_remote_server.delay'):
user = models.User.objects.create_user(
'rat', 'rat@rat.rat', 'ratword', local=False,
remote_id='https://example.com/dfjkg')
self.assertEqual(user.username, 'rat@example.com')
def test_user_shelves(self): def test_user_shelves(self):
@ -53,7 +61,6 @@ class User(TestCase):
self.assertEqual(activity['name'], self.user.name) self.assertEqual(activity['name'], self.user.name)
self.assertEqual(activity['inbox'], self.user.inbox) self.assertEqual(activity['inbox'], self.user.inbox)
self.assertEqual(activity['outbox'], self.user.outbox) self.assertEqual(activity['outbox'], self.user.outbox)
self.assertEqual(activity['followers'], self.user.ap_followers)
self.assertEqual(activity['bookwyrmUser'], True) self.assertEqual(activity['bookwyrmUser'], True)
self.assertEqual(activity['discoverable'], True) self.assertEqual(activity['discoverable'], True)
self.assertEqual(activity['type'], 'Person') self.assertEqual(activity['type'], 'Person')

View file

@ -2,17 +2,19 @@ from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, outgoing from bookwyrm import models, outgoing
from bookwyrm.settings import DOMAIN
class Following(TestCase): class Following(TestCase):
def setUp(self): def setUp(self):
self.remote_user = models.User.objects.create_user( with patch('bookwyrm.models.user.set_remote_server'):
'rat', 'rat@rat.com', 'ratword', self.remote_user = models.User.objects.create_user(
local=False, 'rat', 'rat@rat.com', 'ratword',
remote_id='https://example.com/users/rat', local=False,
inbox='https://example.com/users/rat/inbox', remote_id='https://example.com/users/rat',
outbox='https://example.com/users/rat/outbox', inbox='https://example.com/users/rat/inbox',
) outbox='https://example.com/users/rat/outbox',
)
self.local_user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
'mouse', 'mouse@mouse.com', 'mouseword', 'mouse', 'mouse@mouse.com', 'mouseword',
local=True, local=True,
@ -23,7 +25,7 @@ class Following(TestCase):
def test_handle_follow(self): def test_handle_follow(self):
self.assertEqual(models.UserFollowRequest.objects.count(), 0) self.assertEqual(models.UserFollowRequest.objects.count(), 0)
with patch('bookwyrm.broadcast.broadcast_task.delay') as _: with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_follow(self.local_user, self.remote_user) outgoing.handle_follow(self.local_user, self.remote_user)
rel = models.UserFollowRequest.objects.get() rel = models.UserFollowRequest.objects.get()
@ -36,7 +38,7 @@ class Following(TestCase):
def test_handle_unfollow(self): def test_handle_unfollow(self):
self.remote_user.followers.add(self.local_user) self.remote_user.followers.add(self.local_user)
self.assertEqual(self.remote_user.followers.count(), 1) self.assertEqual(self.remote_user.followers.count(), 1)
with patch('bookwyrm.broadcast.broadcast_task.delay') as _: with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_unfollow(self.local_user, self.remote_user) outgoing.handle_unfollow(self.local_user, self.remote_user)
self.assertEqual(self.remote_user.followers.count(), 0) self.assertEqual(self.remote_user.followers.count(), 0)
@ -49,7 +51,7 @@ class Following(TestCase):
) )
rel_id = rel.id rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay') as _: with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_accept(rel) outgoing.handle_accept(rel)
# request should be deleted # request should be deleted
self.assertEqual( self.assertEqual(
@ -66,7 +68,7 @@ class Following(TestCase):
) )
rel_id = rel.id rel_id = rel.id
with patch('bookwyrm.broadcast.broadcast_task.delay') as _: with patch('bookwyrm.broadcast.broadcast_task.delay'):
outgoing.handle_reject(rel) outgoing.handle_reject(rel)
# request should be deleted # request should be deleted
self.assertEqual( self.assertEqual(

View file

@ -0,0 +1,61 @@
''' testing user lookup '''
import json
import pathlib
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import models, outgoing
from bookwyrm.settings import DOMAIN
class TestOutgoingRemoteWebfinger(TestCase):
''' overwrites standard model feilds to work with activitypub '''
def setUp(self):
''' get user data ready '''
datafile = pathlib.Path(__file__).parent.joinpath(
'../data/ap_user.json'
)
self.userdata = json.loads(datafile.read_bytes())
del self.userdata['icon']
def test_existing_user(self):
''' simple database lookup by username '''
user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
result = outgoing.handle_remote_webfinger('@mouse@%s' % DOMAIN)
self.assertEqual(result, user)
result = outgoing.handle_remote_webfinger('mouse@%s' % DOMAIN)
self.assertEqual(result, user)
@responses.activate
def test_load_user(self):
username = 'mouse@example.com'
wellknown = {
"subject": "acct:mouse@example.com",
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": "https://example.com/user/mouse"
}
]
}
responses.add(
responses.GET,
'https://example.com/.well-known/webfinger?resource=acct:%s' \
% username,
json=wellknown,
status=200)
responses.add(
responses.GET,
'https://example.com/user/mouse',
json=self.userdata,
status=200)
with patch('bookwyrm.models.user.set_remote_server.delay'):
result = outgoing.handle_remote_webfinger('@mouse@example.com')
self.assertIsInstance(result, models.User)
self.assertEqual(result.username, 'mouse@example.com')

View file

@ -1,3 +1,4 @@
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, broadcast from bookwyrm import models, broadcast
@ -6,44 +7,45 @@ from bookwyrm import models, broadcast
class Book(TestCase): class Book(TestCase):
def setUp(self): def setUp(self):
self.user = models.User.objects.create_user( self.user = models.User.objects.create_user(
'mouse', 'mouse@mouse.mouse', 'mouseword') 'mouse', 'mouse@mouse.mouse', 'mouseword', local=True)
follower = models.User.objects.create_user(
'rat', 'rat@mouse.mouse', 'ratword', local=False,
remote_id='http://example.com/u/1',
outbox='http://example.com/u/1/o',
shared_inbox='http://example.com/inbox',
inbox='http://example.com/u/1/inbox')
self.user.followers.add(follower)
no_inbox_follower = models.User.objects.create_user(
'hamster', 'hamster@mouse.mouse', 'hamword',
shared_inbox=None, local=False,
remote_id='http://example.com/u/2',
outbox='http://example.com/u/2/o',
inbox='http://example.com/u/2/inbox')
self.user.followers.add(no_inbox_follower)
non_fr_follower = models.User.objects.create_user(
'gerbil', 'gerb@mouse.mouse', 'gerbword',
remote_id='http://example.com/u/3',
outbox='http://example2.com/u/3/o',
inbox='http://example2.com/u/3/inbox',
shared_inbox='http://example2.com/inbox',
bookwyrm_user=False, local=False)
self.user.followers.add(non_fr_follower)
local_follower = models.User.objects.create_user( local_follower = models.User.objects.create_user(
'joe', 'joe@mouse.mouse', 'jeoword') 'joe', 'joe@mouse.mouse', 'jeoword', local=True)
self.user.followers.add(local_follower) self.user.followers.add(local_follower)
models.User.objects.create_user( with patch('bookwyrm.models.user.set_remote_server.delay'):
'nutria', 'nutria@mouse.mouse', 'nuword', follower = models.User.objects.create_user(
remote_id='http://example.com/u/4', 'rat', 'rat@mouse.mouse', 'ratword', local=False,
outbox='http://example.com/u/4/o', remote_id='http://example.com/u/1',
shared_inbox='http://example.com/inbox', outbox='http://example.com/u/1/o',
inbox='http://example.com/u/4/inbox', shared_inbox='http://example.com/inbox',
local=False) inbox='http://example.com/u/1/inbox')
self.user.followers.add(follower)
no_inbox_follower = models.User.objects.create_user(
'hamster', 'hamster@mouse.mouse', 'hamword',
shared_inbox=None, local=False,
remote_id='http://example.com/u/2',
outbox='http://example.com/u/2/o',
inbox='http://example.com/u/2/inbox')
self.user.followers.add(no_inbox_follower)
non_fr_follower = models.User.objects.create_user(
'gerbil', 'gerb@mouse.mouse', 'gerbword',
remote_id='http://example.com/u/3',
outbox='http://example2.com/u/3/o',
inbox='http://example2.com/u/3/inbox',
shared_inbox='http://example2.com/inbox',
bookwyrm_user=False, local=False)
self.user.followers.add(non_fr_follower)
models.User.objects.create_user(
'nutria', 'nutria@mouse.mouse', 'nuword',
remote_id='http://example.com/u/4',
outbox='http://example.com/u/4/o',
shared_inbox='http://example.com/inbox',
inbox='http://example.com/u/4/inbox',
local=False)
def test_get_public_recipients(self): def test_get_public_recipients(self):

View file

@ -1,27 +0,0 @@
import json
import pathlib
from django.test import TestCase
from bookwyrm import models, remote_user
class RemoteUser(TestCase):
''' not too much going on in the books model but here we are '''
def setUp(self):
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',
)
datafile = pathlib.Path(__file__).parent.joinpath(
'data/ap_user.json'
)
self.user_data = json.loads(datafile.read_bytes())
def test_get_remote_user(self):
actor = 'https://example.com/users/rat'
user = remote_user.get_or_create_remote_user(actor)
self.assertEqual(user, self.remote_user)

View file

@ -25,20 +25,23 @@ def get_follow_data(follower, followee):
).serialize() ).serialize()
return json.dumps(follow_activity) return json.dumps(follow_activity)
Sender = namedtuple('Sender', ('remote_id', 'private_key', 'public_key')) KeyPair = namedtuple('KeyPair', ('private_key', 'public_key'))
Sender = namedtuple('Sender', ('remote_id', 'key_pair'))
class Signature(TestCase): class Signature(TestCase):
def setUp(self): def setUp(self):
self.mouse = User.objects.create_user('mouse', 'mouse@example.com', '') self.mouse = User.objects.create_user(
self.rat = User.objects.create_user('rat', 'rat@example.com', '') 'mouse', 'mouse@example.com', '', local=True)
self.cat = User.objects.create_user('cat', 'cat@example.com', '') self.rat = User.objects.create_user(
'rat', 'rat@example.com', '', local=True)
self.cat = User.objects.create_user(
'cat', 'cat@example.com', '', local=True)
private_key, public_key = create_key_pair() private_key, public_key = create_key_pair()
self.fake_remote = Sender( self.fake_remote = Sender(
'http://localhost/user/remote', 'http://localhost/user/remote',
private_key, KeyPair(private_key, public_key)
public_key,
) )
def send(self, signature, now, data, digest): def send(self, signature, now, data, digest):
@ -70,8 +73,9 @@ class Signature(TestCase):
digest = digest or make_digest(data) digest = digest or make_digest(data)
signature = make_signature( signature = make_signature(
signer or sender, self.rat.inbox, now, digest) signer or sender, self.rat.inbox, now, digest)
with patch('bookwyrm.incoming.handle_follow.delay') as _: with patch('bookwyrm.incoming.handle_follow.delay'):
return self.send(signature, now, send_data or data, digest) with patch('bookwyrm.models.user.set_remote_server.delay'):
return self.send(signature, now, send_data or data, digest)
def test_correct_signature(self): def test_correct_signature(self):
response = self.send_test_request(sender=self.mouse) response = self.send_test_request(sender=self.mouse)
@ -89,7 +93,7 @@ class Signature(TestCase):
datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json') datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json')
data = json.loads(datafile.read_bytes()) data = json.loads(datafile.read_bytes())
data['id'] = self.fake_remote.remote_id data['id'] = self.fake_remote.remote_id
data['publicKey']['publicKeyPem'] = self.fake_remote.public_key data['publicKey']['publicKeyPem'] = self.fake_remote.key_pair.public_key
del data['icon'] # Avoid having to return an avatar. del data['icon'] # Avoid having to return an avatar.
responses.add( responses.add(
responses.GET, responses.GET,
@ -107,7 +111,7 @@ class Signature(TestCase):
status=200 status=200
) )
with patch('bookwyrm.remote_user.get_remote_reviews.delay') as _: with patch('bookwyrm.models.user.get_remote_reviews.delay'):
response = self.send_test_request(sender=self.fake_remote) response = self.send_test_request(sender=self.fake_remote)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -116,7 +120,7 @@ class Signature(TestCase):
datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json') datafile = pathlib.Path(__file__).parent.joinpath('data/ap_user.json')
data = json.loads(datafile.read_bytes()) data = json.loads(datafile.read_bytes())
data['id'] = self.fake_remote.remote_id data['id'] = self.fake_remote.remote_id
data['publicKey']['publicKeyPem'] = self.fake_remote.public_key data['publicKey']['publicKeyPem'] = self.fake_remote.key_pair.public_key
del data['icon'] # Avoid having to return an avatar. del data['icon'] # Avoid having to return an avatar.
responses.add( responses.add(
responses.GET, responses.GET,
@ -127,25 +131,18 @@ class Signature(TestCase):
responses.GET, responses.GET,
'https://localhost/.well-known/nodeinfo', 'https://localhost/.well-known/nodeinfo',
status=404) status=404)
responses.add(
responses.GET,
'https://example.com/user/mouse/outbox?page=true',
json={'orderedItems': []},
status=200
)
# Second and subsequent fetches get a different key: # Second and subsequent fetches get a different key:
new_private_key, new_public_key = create_key_pair() key_pair = KeyPair(*create_key_pair())
new_sender = Sender( new_sender = Sender(self.fake_remote.remote_id, key_pair)
self.fake_remote.remote_id, new_private_key, new_public_key) data['publicKey']['publicKeyPem'] = key_pair.public_key
data['publicKey']['publicKeyPem'] = new_public_key
responses.add( responses.add(
responses.GET, responses.GET,
self.fake_remote.remote_id, self.fake_remote.remote_id,
json=data, json=data,
status=200) status=200)
with patch('bookwyrm.remote_user.get_remote_reviews.delay') as _: with patch('bookwyrm.models.user.get_remote_reviews.delay'):
# Key correct: # Key correct:
response = self.send_test_request(sender=self.fake_remote) response = self.send_test_request(sender=self.fake_remote)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
@ -177,7 +174,7 @@ class Signature(TestCase):
@pytest.mark.integration @pytest.mark.integration
def test_changed_data(self): def test_changed_data(self):
'''Message data must match the digest header.''' '''Message data must match the digest header.'''
with patch('bookwyrm.remote_user.fetch_user_data') as _: with patch('bookwyrm.activitypub.resolve_remote_id'):
response = self.send_test_request( response = self.send_test_request(
self.mouse, self.mouse,
send_data=get_follow_data(self.mouse, self.cat)) send_data=get_follow_data(self.mouse, self.cat))
@ -185,7 +182,7 @@ class Signature(TestCase):
@pytest.mark.integration @pytest.mark.integration
def test_invalid_digest(self): def test_invalid_digest(self):
with patch('bookwyrm.remote_user.fetch_user_data') as _: with patch('bookwyrm.activitypub.resolve_remote_id'):
response = self.send_test_request( response = self.send_test_request(
self.mouse, self.mouse,
digest='SHA-256=AAAAAAAAAAAAAAAAAA') digest='SHA-256=AAAAAAAAAAAAAAAAAA')
@ -194,7 +191,7 @@ class Signature(TestCase):
@pytest.mark.integration @pytest.mark.integration
def test_old_message(self): def test_old_message(self):
'''Old messages should be rejected to prevent replay attacks.''' '''Old messages should be rejected to prevent replay attacks.'''
with patch('bookwyrm.remote_user.fetch_user_data') as _: with patch('bookwyrm.activitypub.resolve_remote_id'):
response = self.send_test_request( response = self.send_test_request(
self.mouse, self.mouse,
date=http_date(time.time() - 301) date=http_date(time.time() - 301)

View file

@ -2,4 +2,4 @@
domain = r'[a-z-A-Z0-9_\-]+\.[a-z]+' domain = r'[a-z-A-Z0-9_\-]+\.[a-z]+'
username = r'@[a-zA-Z_\-\.0-9]+(@%s)?' % domain username = r'@[a-zA-Z_\-\.0-9]+(@%s)?' % domain
full_username = r'@[a-zA-Z_\-\.0-9]+@%s' % domain full_username = r'@?[a-zA-Z_\-\.0-9]+@%s' % domain

View file

@ -82,7 +82,8 @@ def register(request):
} }
return TemplateResponse(request, 'login.html', data) return TemplateResponse(request, 'login.html', data)
user = models.User.objects.create_user(username, email, password) user = models.User.objects.create_user(
username, email, password, local=True)
if invite: if invite:
invite.times_used += 1 invite.times_used += 1
invite.save() invite.save()
@ -217,7 +218,9 @@ def edit_profile(request):
def resolve_book(request): def resolve_book(request):
''' figure out the local path to a book from a remote_id ''' ''' figure out the local path to a book from a remote_id '''
remote_id = request.POST.get('remote_id') remote_id = request.POST.get('remote_id')
book = books_manager.get_or_create_book(remote_id) connector = books_manager.get_or_create_connector(remote_id)
book = connector.get_or_create_book(remote_id)
return redirect('/book/%d' % book.id) return redirect('/book/%d' % book.id)
@ -529,12 +532,15 @@ def tag(request):
book = get_object_or_404(models.Edition, id=book_id) book = get_object_or_404(models.Edition, id=book_id)
tag_obj, created = models.Tag.objects.get_or_create( tag_obj, created = models.Tag.objects.get_or_create(
name=name, name=name,
)
user_tag = models.UserTag.objects.get_or_create(
user=request.user,
book=book, book=book,
user=request.user tag=tag_obj,
) )
if created: if created:
outgoing.handle_tag(request.user, tag_obj) outgoing.handle_tag(request.user, user_tag)
return redirect('/book/%s' % book_id) return redirect('/book/%s' % book_id)

View file

@ -5,8 +5,7 @@ from django.contrib.auth.decorators import login_required, permission_required
from django.contrib.postgres.search import TrigramSimilarity from django.contrib.postgres.search import TrigramSimilarity
from django.core.paginator import Paginator from django.core.paginator import Paginator
from django.db.models import Avg, Q from django.db.models import Avg, Q
from django.http import HttpResponseBadRequest, HttpResponseNotFound,\ from django.http import HttpResponseNotFound, JsonResponse
JsonResponse
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
@ -534,7 +533,7 @@ def book_page(request, book_id):
return JsonResponse(book.to_activity(), encoder=ActivityEncoder) return JsonResponse(book.to_activity(), encoder=ActivityEncoder)
if isinstance(book, models.Work): if isinstance(book, models.Work):
book = book.default_edition book = book.get_default_edition()
if not book: if not book:
return HttpResponseNotFound() return HttpResponseNotFound()
@ -543,7 +542,7 @@ def book_page(request, book_id):
return HttpResponseNotFound() return HttpResponseNotFound()
reviews = models.Review.objects.filter( reviews = models.Review.objects.filter(
book__in=work.edition_set.all(), book__in=work.editions.all(),
) )
# all reviews for the book # all reviews for the book
reviews = get_activity_feed(request.user, 'federated', model=reviews) reviews = get_activity_feed(request.user, 'federated', model=reviews)
@ -563,9 +562,9 @@ def book_page(request, book_id):
user_tags = [] user_tags = []
readthroughs = [] readthroughs = []
if request.user.is_authenticated: if request.user.is_authenticated:
user_tags = models.Tag.objects.filter( user_tags = models.UserTag.objects.filter(
book=book, user=request.user book=book, user=request.user
).values_list('identifier', flat=True) ).values_list('tag__identifier', flat=True)
readthroughs = models.ReadThrough.objects.filter( readthroughs = models.ReadThrough.objects.filter(
user=request.user, user=request.user,
@ -573,11 +572,9 @@ def book_page(request, book_id):
).order_by('start_date') ).order_by('start_date')
rating = reviews.aggregate(Avg('rating')) rating = reviews.aggregate(Avg('rating'))
tags = models.Tag.objects.filter( tags = models.UserTag.objects.filter(
book=book book=book,
).values( )
'book', 'name', 'identifier'
).distinct().all()
data = { data = {
'title': book.title, 'title': book.title,
@ -651,7 +648,7 @@ def author_page(request, author_id):
data = { data = {
'title': author.name, 'title': author.name,
'author': author, 'author': author,
'books': [b.default_edition for b in books], 'books': [b.get_default_edition() for b in books],
} }
return TemplateResponse(request, 'author.html', data) return TemplateResponse(request, 'author.html', data)
@ -667,7 +664,9 @@ def tag_page(request, tag_id):
return JsonResponse( return JsonResponse(
tag_obj.to_activity(**request.GET), encoder=ActivityEncoder) tag_obj.to_activity(**request.GET), encoder=ActivityEncoder)
books = models.Edition.objects.filter(tag__identifier=tag_id).distinct() books = models.Edition.objects.filter(
usertag__tag__identifier=tag_id
).distinct()
data = { data = {
'title': tag_obj.name, 'title': tag_obj.name,
'books': books, 'books': books,

View file

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