1
0
Fork 0

Merge branch 'main' into logo-default

This commit is contained in:
Mouse Reeve 2020-12-12 16:03:19 -08:00
commit ae07bbffb7
230 changed files with 8169 additions and 4734 deletions

View file

@ -2,15 +2,19 @@
import inspect
import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature
from .base_activity import ActivityEncoder, Signature
from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, resolve_remote_id
from .image import Image
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person
from .person import Person, PublicKey
from .book import Edition, Work, Author
from .verbs import Create, Undo, Update
from .verbs import Create, Delete, Undo, Update
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,
# so when an Activity comes in from outside, we can check if it's known

View file

@ -2,8 +2,16 @@
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
from django.db.models.fields.related_descriptors \
import ForwardManyToOneDescriptor
from django.apps import apps
from django.db import transaction
from django.db.models.fields.files import ImageFileDescriptor
from django.db.models.fields.related_descriptors import ManyToManyDescriptor
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
class ActivitySerializerError(ValueError):
''' routine problems serializing activitypub json '''
class ActivityEncoder(JSONEncoder):
@ -13,19 +21,17 @@ class ActivityEncoder(JSONEncoder):
@dataclass
class Image:
''' image block '''
mediaType: str
url: str
type: str = 'Image'
class Link:
''' for tagging a book in a status '''
href: str
name: str
type: str = 'Link'
@dataclass
class PublicKey:
''' public key block '''
id: str
owner: str
publicKeyPem: str
class Mention(Link):
''' a subtype of Link for mentioning an actor '''
type: str = 'Mention'
@dataclass
@ -44,55 +50,104 @@ class ActivityObject:
type: str
def __init__(self, **kwargs):
''' this lets you pass in an object with fields
that aren't in the dataclass, which it ignores.
Any field in the dataclass is required or has a
default value '''
''' this lets you pass in an object with fields that aren't in the
dataclass, which it ignores. Any field in the dataclass is required or
has a default value '''
for field in fields(self):
try:
value = kwargs[field.name]
except KeyError:
if field.default == MISSING:
raise TypeError('Missing required field: %s' % field.name)
if field.default == MISSING and \
field.default_factory == MISSING:
raise ActivitySerializerError(\
'Missing required field: %s' % field.name)
value = field.default
setattr(self, field.name, value)
def to_model(self, model, instance=None):
''' convert from an activity to a model '''
@transaction.atomic
def to_model(self, model, instance=None, save=True):
''' convert from an activity to a model instance '''
if not isinstance(self, model.activity_serializer):
raise TypeError('Wrong activity type for model')
raise ActivitySerializerError(
'Wrong activity type "%s" for model "%s" (expects "%s")' % \
(self.__class__,
model.__name__,
model.activity_serializer)
)
model_fields = [m.name for m in model._meta.get_fields()]
mapped_fields = {}
# check for an existing instance, if we're not updating a known obj
if not instance:
instance = model.find_existing(self.serialize()) or model()
for mapping in model.activity_mappings:
if mapping.model_key not in model_fields:
many_to_many_fields = {}
image_fields = {}
for field in model._meta.get_fields():
# check if it's an activitypub field
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
# 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)
# remote_id -> foreign key resolver
if isinstance(model_field, ForwardManyToOneDescriptor) and value:
fk_model = model_field.field.related_model
value = resolve_foreign_key(fk_model, value)
model_field = getattr(model, field.name)
mapped_fields[mapping.model_key] = mapping.model_formatter(value)
if isinstance(model_field, ManyToManyDescriptor):
# status mentions book/users for example, stash this for later
many_to_many_fields[field.name] = value
elif isinstance(model_field, ImageFileDescriptor):
# image fields need custom handling
image_fields[field.name] = value
else:
# just a good old fashioned model.field = value
setattr(instance, field.name, value)
# if this isn't here, it messes up saving users. who even knows.
for (model_key, value) in image_fields.items():
getattr(instance, model_key).save(*value, save=save)
# updating an existing model isntance
if instance:
for k, v in mapped_fields.items():
setattr(instance, k, v)
instance.save()
if not save:
# we can't set many to many and reverse fields on an unsaved object
return instance
# creating a new model instance
return model.objects.create(**mapped_fields)
instance.save()
# add many to many fields, which have to be set post-save
for (model_key, values) in many_to_many_fields.items():
# mention books/users, for example
getattr(instance, model_key).set(values)
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
def serialize(self):
@ -102,17 +157,57 @@ class ActivityObject:
return data
def resolve_foreign_key(model, remote_id):
''' look up the remote_id on an activity json field '''
result = model.objects
if hasattr(model.objects, 'select_subclasses'):
result = result.select_subclasses()
@app.task
@transaction.atomic
def set_related_field(
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 = result.filter(
remote_id=remote_id
).first()
if isinstance(data, str):
item = resolve_remote_id(model, data, save=False)
else:
# look for a match based on all the available data
item = model.find_existing(data)
if not item:
# create a new model instance
item = model.activity_serializer(**data)
item = item.to_model(model, save=False)
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError('Invalid related remote id: %s' % related_remote_id)
if not result:
raise ValueError('Could not resolve remote_id in %s model: %s' % \
# edition.parent_work = instance, for example
setattr(item, related_field_name, instance)
item.save()
def resolve_remote_id(model, remote_id, refresh=False, save=True):
''' 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))
return result
# 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

@ -2,66 +2,66 @@
from dataclasses import dataclass, field
from typing import List
from .base_activity import ActivityObject, Image
from .base_activity import ActivityObject
from .image import Image
@dataclass(init=False)
class Book(ActivityObject):
''' serializes an edition or work, abstract '''
authors: List[str]
first_published_date: str
published_date: str
title: str
sort_title: str
subtitle: str
description: str
languages: List[str]
series: str
series_number: str
subjects: List[str]
subject_places: List[str]
sortTitle: str = ''
subtitle: str = ''
description: str = ''
languages: List[str] = field(default_factory=lambda: [])
series: str = ''
seriesNumber: str = ''
subjects: List[str] = field(default_factory=lambda: [])
subjectPlaces: List[str] = field(default_factory=lambda: [])
openlibrary_key: str
librarything_key: str
goodreads_key: str
authors: List[str] = field(default_factory=lambda: [])
firstPublishedDate: str = ''
publishedDate: str = ''
attachment: List[Image] = field(default=lambda: [])
openlibraryKey: str = ''
librarythingKey: str = ''
goodreadsKey: str = ''
cover: Image = field(default_factory=lambda: {})
type: str = 'Book'
@dataclass(init=False)
class Edition(Book):
''' Edition instance of a book object '''
isbn_10: str
isbn_13: str
oclc_number: str
asin: str
pages: str
physical_format: str
publishers: List[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'
@dataclass(init=False)
class Work(Book):
''' work instance of a book object '''
lccn: str
lccn: str = ''
defaultEdition: str = ''
editions: List[str]
type: str = 'Work'
@dataclass(init=False)
class Author(ActivityObject):
''' author of a book '''
url: str
name: str
born: str
died: str
aliases: str
bio: str
openlibrary_key: str
wikipedia_link: str
born: str = ''
died: str = ''
aliases: str = ''
bio: str = ''
openlibraryKey: str = ''
wikipediaLink: str = ''
type: str = 'Person'

View file

@ -0,0 +1,11 @@
''' an image, nothing fancy '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Image(ActivityObject):
''' image block '''
url: str
name: str = ''
type: str = 'Image'
id: str = ''

View file

@ -2,21 +2,29 @@
from dataclasses import dataclass, field
from typing import Dict, List
from .base_activity import ActivityObject, Image
from .base_activity import ActivityObject, Link
from .image import Image
@dataclass(init=False)
class Tombstone(ActivityObject):
''' the placeholder for a deleted status '''
published: str
deleted: str
type: str = 'Tombstone'
@dataclass(init=False)
class Note(ActivityObject):
''' Note activity '''
url: str
inReplyTo: str
published: str
attributedTo: str
to: List[str]
cc: List[str]
content: str
replies: Dict
# TODO: this is wrong???
attachment: List[Image] = field(default=lambda: [])
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: [])
attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False
type: str = 'Note'

View file

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

View file

@ -2,7 +2,17 @@
from dataclasses import dataclass, field
from typing import Dict
from .base_activity import ActivityObject, Image, PublicKey
from .base_activity import ActivityObject
from .image import Image
@dataclass(init=False)
class PublicKey(ActivityObject):
''' public key block '''
owner: str
publicKeyPem: str
type: str = 'PublicKey'
@dataclass(init=False)
class Person(ActivityObject):
@ -15,8 +25,8 @@ class Person(ActivityObject):
summary: str
publicKey: PublicKey
endpoints: Dict
icon: Image = field(default=lambda: {})
bookwyrmUser: str = False
icon: Image = field(default_factory=lambda: {})
bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False
discoverable: str = True
type: str = 'Person'

View file

@ -3,6 +3,7 @@ from dataclasses import dataclass
from typing import List
from .base_activity import ActivityObject, Signature
from .book import Book
@dataclass(init=False)
class Verb(ActivityObject):
@ -21,6 +22,14 @@ class Create(Verb):
type: str = 'Create'
@dataclass(init=False)
class Delete(Verb):
''' Create activity '''
to: List
cc: List
type: str = 'Delete'
@dataclass(init=False)
class Update(Verb):
''' Update activity '''
@ -61,6 +70,13 @@ class Add(Verb):
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)
class Remove(Verb):
'''Remove activity '''

View file

@ -4,3 +4,5 @@ from bookwyrm import models
admin.site.register(models.SiteSettings)
admin.site.register(models.User)
admin.site.register(models.FederatedServer)
admin.site.register(models.Connector)

View file

@ -16,23 +16,6 @@ def get_edition(book_id):
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):
''' get the connector related to the author's server '''
url = urlparse(remote_id)
@ -50,7 +33,7 @@ def get_or_create_connector(remote_id):
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
priority=3
priority=2
)
return load_connector(connector_info)
@ -64,14 +47,14 @@ def load_more_data(book_id):
connector.expand_book_data(book)
def search(query):
def search(query, min_confidence=0.1):
''' find books based on arbitary keywords '''
results = []
dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year)
result_index = set()
for connector in get_connectors():
try:
result_set = connector.search(query)
result_set = connector.search(query, min_confidence=min_confidence)
except HTTPError:
continue
@ -87,27 +70,21 @@ def search(query):
return results
def local_search(query):
def local_search(query, min_confidence=0.1):
''' only look at local search results '''
connector = load_connector(models.Connector.objects.get(local=True))
return connector.search(query)
return connector.search(query, min_confidence=min_confidence)
def first_search_result(query):
def first_search_result(query, min_confidence=0.1):
''' search until you find a result that fits '''
for connector in get_connectors():
result = connector.search(query)
result = connector.search(query, min_confidence=min_confidence)
if result:
return result[0]
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():
''' load all connectors '''
for info in models.Connector.objects.order_by('priority').all():

View file

@ -13,7 +13,6 @@ def get_public_recipients(user, software=None):
''' everybody and their public inboxes '''
followers = user.followers.filter(local=False)
if software:
# TODO: eventually we may want to handle particular software differently
followers = followers.filter(bookwyrm_user=(software == 'bookwyrm'))
# we want shared inboxes when available
@ -36,7 +35,6 @@ def broadcast(sender, activity, software=None, \
# start with parsing the direct recipients
recipients = [u.inbox for u in direct_recipients or []]
# and then add any other recipients
# TODO: other kinds of privacy
if privacy == 'public':
recipients += get_public_recipients(sender, software=software)
broadcast_task.delay(
@ -55,7 +53,6 @@ def broadcast_task(sender_id, activity, recipients):
try:
sign_and_send(sender, activity, recipient)
except requests.exceptions.HTTPError as e:
# TODO: maybe keep track of users who cause errors
errors.append({
'error': str(e),
'recipient': recipient,
@ -64,15 +61,14 @@ def broadcast_task(sender_id, activity, recipients):
return errors
def sign_and_send(sender, activity, destination):
def sign_and_send(sender, data, destination):
''' crpyto whatever and http junk '''
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.
raise ValueError('No private key found for sender')
data = json.dumps(activity).encode('utf-8')
digest = make_digest(data)
response = requests.post(

View file

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

View file

@ -1,32 +1,29 @@
''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod
from dateutil import parser
from dataclasses import dataclass
import pytz
import requests
from urllib3.exceptions import RequestError
from django.db import transaction
from dateutil import parser
import requests
from requests import HTTPError
from requests.exceptions import SSLError
from bookwyrm import models
class ConnectorException(Exception):
class ConnectorException(HTTPError):
''' when the connector can't do what was asked '''
class AbstractConnector(ABC):
''' generic book data connector '''
class AbstractMinimalConnector(ABC):
''' just the bare bones, for other bookwyrm instances '''
def __init__(self, identifier):
# load connector settings
info = models.Connector.objects.get(identifier=identifier)
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
self_fields = [
'base_url',
@ -41,16 +38,7 @@ class AbstractConnector(ABC):
for field in self_fields:
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):
def search(self, query, min_confidence=None):
''' free text search '''
resp = requests.get(
'%s%s' % (self.search_url, query),
@ -67,12 +55,43 @@ class AbstractConnector(ABC):
results.append(self.format_search_result(doc))
return results
@abstractmethod
def get_or_create_book(self, remote_id):
''' 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
book = models.Book.objects.select_subclasses().filter(
remote_id=remote_id
origin_id=remote_id
).first()
if book:
if isinstance(book, models.Work):
@ -112,22 +131,26 @@ class AbstractConnector(ABC):
# remember this hack: re-use the work data as the edition data
work_data = data
if not work_data or not edition_data:
raise ConnectorException('Unable to load book data: %s' % remote_id)
# at this point, we need to figure out the work, edition, or both
# atomic so that we don't save a work with no edition for vice versa
with transaction.atomic():
if not work:
work_key = work_data.get('url')
work_key = self.get_remote_id_from_data(work_data)
work = self.create_book(work_key, work_data, models.Work)
if not edition:
ed_key = edition_data.get('url')
ed_key = self.get_remote_id_from_data(edition_data)
edition = self.create_book(ed_key, edition_data, models.Edition)
edition.default = True
edition.parent_work = work
edition.save()
work.default_edition = edition
work.save()
# now's our change to fill in author gaps
if not edition.authors and work.authors:
if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all())
edition.author_text = work.author_text
edition.save()
@ -141,7 +164,7 @@ class AbstractConnector(ABC):
def create_book(self, remote_id, data, model):
''' create a work or edition from data '''
book = model.objects.create(
remote_id=remote_id,
origin_id=remote_id,
title=data['title'],
connector=self.connector,
)
@ -152,9 +175,11 @@ class AbstractConnector(ABC):
''' for creating a new book or syncing with data '''
book = update_from_mappings(book, data, self.book_mappings)
author_text = []
for author in self.get_authors_from_data(data):
book.authors.add(author)
book.author_text = ', '.join(a.display_name for a in book.authors.all())
author_text.append(author.name)
book.author_text = ', '.join(author_text)
book.save()
if not update_cover:
@ -207,6 +232,11 @@ class AbstractConnector(ABC):
return None
@abstractmethod
def get_remote_id_from_data(self, data):
''' otherwise we won't properly set the remote_id in the db '''
@abstractmethod
def is_work_data(self, data):
''' differentiate works and editions '''
@ -231,17 +261,6 @@ class AbstractConnector(ABC):
def get_cover_from_data(self, data):
''' 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
def expand_book_data(self, book):
''' get more info on a book '''
@ -284,25 +303,44 @@ def get_date(date_string):
def get_data(url):
''' wrapper for request.get '''
resp = requests.get(
url,
headers={
'Accept': 'application/json; charset=utf-8',
},
)
try:
resp = requests.get(
url,
headers={
'Accept': 'application/json; charset=utf-8',
},
)
except RequestError:
raise ConnectorException()
if not resp.ok:
resp.raise_for_status()
data = resp.json()
try:
data = resp.json()
except ValueError:
raise ConnectorException()
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
class SearchResult:
''' standardized search result object '''
def __init__(self, title, key, author, year):
self.title = title
self.key = key
self.author = author
self.year = year
title: str
key: str
author: str
year: str
confidence: int = 1
def __repr__(self):
return "<SearchResult key={!r} title={!r} author={!r}>".format(

View file

@ -1,108 +1,16 @@
''' using another bookwyrm instance as a source of book data '''
from uuid import uuid4
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.base import ContentFile
import requests
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import update_from_mappings, get_date, get_data
from bookwyrm import activitypub, models
from .abstract_connector import AbstractMinimalConnector, SearchResult
class Connector(AbstractConnector):
''' interact with other instances '''
def __init__(self, identifier):
super().__init__(identifier)
self.key_mappings = [
Mapping('isbn_13', model=models.Edition),
Mapping('isbn_10', model=models.Edition),
Mapping('lccn', model=models.Work),
Mapping('oclc_number', model=models.Edition),
Mapping('openlibrary_key'),
Mapping('goodreads_key'),
Mapping('asin'),
]
self.book_mappings = self.key_mappings + [
Mapping('sort_title'),
Mapping('subtitle'),
Mapping('description'),
Mapping('languages'),
Mapping('series'),
Mapping('series_number'),
Mapping('subjects'),
Mapping('subject_places'),
Mapping('first_published_date'),
Mapping('published_date'),
Mapping('pages'),
Mapping('physical_format'),
Mapping('publishers'),
]
self.author_mappings = [
Mapping('born', remote_field='birth_date', formatter=get_date),
Mapping('died', remote_field='death_date', formatter=get_date),
Mapping('bio'),
]
def is_work_data(self, data):
return data['book_type'] == 'Work'
def get_edition_from_work_data(self, data):
return data['editions'][0]
def get_work_from_edition_date(self, data):
return data['work']
def get_authors_from_data(self, data):
for author_url in data.get('authors', []):
yield self.get_or_create_author(author_url)
def get_cover_from_data(self, data):
cover_data = data.get('attachment')
if not cover_data:
return None
cover_url = cover_data[0].get('url')
response = requests.get(cover_url)
if not response.ok:
response.raise_for_status()
image_name = str(uuid4()) + cover_url.split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
def get_or_create_author(self, remote_id):
''' load that author '''
try:
return models.Author.objects.get(remote_id=remote_id)
except ObjectDoesNotExist:
pass
data = get_data(remote_id)
# ingest a new author
author = models.Author(remote_id=remote_id)
author = update_from_mappings(author, data, self.author_mappings)
author.save()
return author
class Connector(AbstractMinimalConnector):
''' this is basically just for search '''
def get_or_create_book(self, remote_id):
return activitypub.resolve_remote_id(models.Edition, remote_id)
def parse_search_data(self, data):
return data
def format_search_result(self, search_result):
return SearchResult(**search_result)
def expand_book_data(self, book):
# TODO
pass

View file

@ -7,8 +7,7 @@ from django.core.files.base import ContentFile
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import ConnectorException
from .abstract_connector import update_from_mappings
from .abstract_connector import get_date, get_data
from .abstract_connector import get_date, get_data, update_from_mappings
from .openlibrary_languages import languages
@ -66,12 +65,20 @@ class Connector(AbstractConnector):
]
self.author_mappings = [
Mapping('name'),
Mapping('born', remote_field='birth_date', formatter=get_date),
Mapping('died', remote_field='death_date', formatter=get_date),
Mapping('bio', formatter=get_description),
]
def get_remote_id_from_data(self, data):
try:
key = data['key']
except KeyError:
raise ConnectorException('Invalid book data')
return '%s/%s' % (self.books_url, key)
def is_work_data(self, data):
return bool(re.match(r'^[\/\w]+OL\d+W$', data['key']))
@ -129,10 +136,10 @@ class Connector(AbstractConnector):
key = self.books_url + search_result['key']
author = search_result.get('author_name') or ['Unknown']
return SearchResult(
search_result.get('title'),
key,
', '.join(author),
search_result.get('first_publish_year'),
title=search_result.get('title'),
key=key,
author=', '.join(author),
year=search_result.get('first_publish_year'),
)
@ -170,21 +177,15 @@ class Connector(AbstractConnector):
''' load that author '''
if not re.match(r'^OL\d+A$', olkey):
raise ValueError('Invalid OpenLibrary author ID')
try:
return models.Author.objects.get(openlibrary_key=olkey)
except models.Author.DoesNotExist:
pass
author = models.Author.objects.filter(openlibrary_key=olkey).first()
if author:
return author
url = '%s/authors/%s.json' % (self.base_url, olkey)
data = get_data(url)
author = models.Author(openlibrary_key=olkey)
author = update_from_mappings(author, data, self.author_mappings)
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()
return author

View file

@ -1,5 +1,6 @@
''' using a bookwyrm instance as a source of book data '''
from django.contrib.postgres.search import SearchRank, SearchVector
from django.db.models import F
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult
@ -7,30 +8,33 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector):
''' instantiate a connector '''
def search(self, query):
def search(self, query, min_confidence=0.1):
''' right now you can't search bookwyrm sorry, but when
that gets implemented it will totally rule '''
vector = SearchVector('title', weight='A') +\
SearchVector('subtitle', weight='B') +\
SearchVector('author_text', weight='A') +\
SearchVector('author_text', weight='C') +\
SearchVector('isbn_13', weight='A') +\
SearchVector('isbn_10', weight='A') +\
SearchVector('openlibrary_key', weight='B') +\
SearchVector('goodreads_key', weight='B') +\
SearchVector('asin', weight='B') +\
SearchVector('oclc_number', weight='B') +\
SearchVector('remote_id', weight='B') +\
SearchVector('description', weight='C') +\
SearchVector('series', weight='C')
SearchVector('openlibrary_key', weight='C') +\
SearchVector('goodreads_key', weight='C') +\
SearchVector('asin', weight='C') +\
SearchVector('oclc_number', weight='C') +\
SearchVector('remote_id', weight='C') +\
SearchVector('description', weight='D') +\
SearchVector('series', weight='D')
results = models.Edition.objects.annotate(
search=vector
).annotate(
rank=SearchRank(vector, query)
).filter(
rank__gt=0
rank__gt=min_confidence
).order_by('-rank')
results = results.filter(default=True) or results
# remove non-default editions, if possible
results = results.filter(parent_work__default_edition__id=F('id')) \
or results
search_results = []
for book in results[:10]:
@ -42,14 +46,18 @@ class Connector(AbstractConnector):
def format_search_result(self, search_result):
return SearchResult(
search_result.title,
search_result.local_id,
search_result.author_text,
search_result.published_date.year if \
title=search_result.title,
key=search_result.remote_id,
author=search_result.author_text,
year=search_result.published_date.year if \
search_result.published_date else None,
confidence=search_result.rank,
)
def get_remote_id_from_data(self, data):
pass
def is_work_data(self, data):
pass

View file

@ -0,0 +1,8 @@
''' customize the info available in context for rendering templates '''
from bookwyrm import models
def site_settings(request):
''' include the custom info about the site '''
return {
'site': models.SiteSettings.objects.get()
}

View file

@ -6,7 +6,6 @@ from bookwyrm.tasks import app
def password_reset_email(reset_code):
''' generate a password reset email '''
# TODO; this should be tempalted
site = models.SiteSettings.get()
send_email.delay(
reset_code.user.email,

View file

@ -5,6 +5,7 @@ from collections import defaultdict
from django import forms
from django.forms import ModelForm, PasswordInput, widgets
from django.forms.widgets import Textarea
from django.utils import timezone
from bookwyrm import models
@ -29,6 +30,7 @@ class CustomForm(ModelForm):
visible.field.widget.attrs['rows'] = None
visible.field.widget.attrs['class'] = css_classes[input_type]
class LoginForm(CustomForm):
class Meta:
model = models.User
@ -52,47 +54,31 @@ class RegisterForm(CustomForm):
class RatingForm(CustomForm):
class Meta:
model = models.Review
fields = ['rating']
fields = ['user', 'book', 'content', 'rating', 'privacy']
class ReviewForm(CustomForm):
class Meta:
model = models.Review
fields = ['name', 'content']
help_texts = {f: None for f in fields}
labels = {
'name': 'Title',
'content': 'Review',
}
fields = ['user', 'book', 'name', 'content', 'rating', 'privacy']
class CommentForm(CustomForm):
class Meta:
model = models.Comment
fields = ['content']
help_texts = {f: None for f in fields}
labels = {
'content': 'Comment',
}
fields = ['user', 'book', 'content', 'privacy']
class QuotationForm(CustomForm):
class Meta:
model = models.Quotation
fields = ['quote', 'content']
help_texts = {f: None for f in fields}
labels = {
'quote': 'Quote',
'content': 'Comment',
}
fields = ['user', 'book', 'quote', 'content', 'privacy']
class ReplyForm(CustomForm):
class Meta:
model = models.Status
fields = ['content']
help_texts = {f: None for f in fields}
labels = {'content': 'Comment'}
fields = ['user', 'content', 'reply_parent', 'privacy']
class EditUserForm(CustomForm):
@ -158,7 +144,7 @@ class ExpiryWidget(widgets.Select):
else:
return selected_string # "This will raise
return datetime.datetime.now() + interval
return timezone.now() + interval
class CreateInviteForm(CustomForm):
class Meta:
@ -174,3 +160,8 @@ class CreateInviteForm(CustomForm):
choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, 'Unlimited')])
}
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ['user', 'name', 'privacy']

View file

@ -1,25 +1,41 @@
''' handle reading a csv from goodreads '''
import csv
from requests import HTTPError
import logging
from bookwyrm import outgoing
from bookwyrm.tasks import app
from bookwyrm.models import ImportJob, ImportItem
from bookwyrm.status import create_notification
logger = logging.getLogger(__name__)
# TODO: remove or increase once we're confident it's not causing problems.
MAX_ENTRIES = 500
def create_job(user, csv_file):
def create_job(user, csv_file, include_reviews, privacy):
''' check over a csv and creates a database entry for the job'''
job = ImportJob.objects.create(user=user)
job = ImportJob.objects.create(
user=user,
include_reviews=include_reviews,
privacy=privacy
)
for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]):
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
raise ValueError("Author, title, and isbn must be in data.")
raise ValueError('Author, title, and isbn must be in data.')
ImportItem(job=job, index=index, data=entry).save()
return job
def create_retry_job(user, original_job, items):
''' retry items that didn't import '''
job = ImportJob.objects.create(
user=user,
include_reviews=original_job.include_reviews,
privacy=original_job.privacy,
retry=True
)
for item in items:
ImportItem(job=job, index=item.index, data=item.data).save()
return job
def start_import(job):
''' initalizes a csv import job '''
@ -37,18 +53,21 @@ def import_data(job_id):
for item in job.items.all():
try:
item.resolve()
except HTTPError:
pass
except Exception as e:
logger.exception(e)
item.fail_reason = 'Error loading book'
item.save()
continue
if item.book:
item.save()
results.append(item)
else:
item.fail_reason = "Could not match book on OpenLibrary"
item.save()
status = outgoing.handle_import_books(job.user, results)
if status:
job.import_status = status
job.save()
# shelves book and handles reviews
outgoing.handle_imported_book(
job.user, item, job.include_reviews, job.privacy)
else:
item.fail_reason = 'Could not find a match for book'
item.save()
finally:
create_notification(job.user, 'IMPORT', related_import=job)

View file

@ -8,9 +8,8 @@ from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
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.remote_user import get_or_create_remote_user, refresh_remote_user
from bookwyrm.tasks import app
from bookwyrm.signatures import Signature
@ -32,12 +31,14 @@ def inbox(request, username):
@csrf_exempt
def shared_inbox(request):
''' incoming activitypub events '''
# TODO: should this be functionally different from the non-shared inbox??
if request.method == 'GET':
return HttpResponseNotFound()
try:
activity = json.loads(request.body)
resp = request.body
activity = json.loads(resp)
if isinstance(activity, str):
activity = json.loads(activity)
activity_object = activity['object']
except (json.decoder.JSONDecodeError, KeyError):
return HttpResponseBadRequest()
@ -54,18 +55,22 @@ def shared_inbox(request):
'Accept': handle_follow_accept,
'Reject': handle_follow_reject,
'Create': handle_create,
'Delete': handle_delete_status,
'Like': handle_favorite,
'Announce': handle_boost,
'Add': {
'Tag': handle_tag,
'Edition': handle_add,
'Work': handle_add,
},
'Undo': {
'Follow': handle_unfollow,
'Like': handle_unfavorite,
'Announce': handle_unboost,
},
'Update': {
'Person': None,# TODO: handle_update_user
'Document': handle_update_book,
'Person': handle_update_user,
'Edition': handle_update_book,
'Work': handle_update_book,
},
}
activity_type = activity['type']
@ -90,16 +95,20 @@ def has_valid_signature(request, activity):
if key_actor != activity.get('actor'):
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:
signature.verify(remote_user.public_key, request)
signature.verify(remote_user.key_pair.public_key, request)
except ValueError:
old_key = remote_user.public_key
refresh_remote_user(remote_user)
if remote_user.public_key == old_key:
old_key = remote_user.key_pair.public_key
remote_user = activitypub.resolve_remote_id(
models.User, remote_user.remote_id, refresh=True
)
if remote_user.key_pair.public_key == old_key:
raise # Key unchanged.
signature.verify(remote_user.public_key, request)
signature.verify(remote_user.key_pair.public_key, request)
except (ValueError, requests.exceptions.HTTPError):
return False
return True
@ -108,47 +117,34 @@ def has_valid_signature(request, activity):
@app.task
def handle_follow(activity):
''' someone wants to follow a local user '''
# figure out who they want to follow -- not using get_or_create because
# we only allow you to follow local users
to_follow = models.User.objects.get(remote_id=activity['object'])
# raises models.User.DoesNotExist id the remote id is not found
# figure out who the actor is
user = get_or_create_remote_user(activity['actor'])
try:
relationship = models.UserFollowRequest.objects.create(
user_subject=user,
user_object=to_follow,
relationship_id=activity['id']
)
relationship = activitypub.Follow(
**activity
).to_model(models.UserFollowRequest)
except django.db.utils.IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
# Duplicate follow request. Not sure what the correct behaviour is, but
# just dropping it works for now. We should perhaps generate the
# Accept, but then do we need to match the activity id?
return
relationship = models.UserFollowRequest.objects.get(
remote_id=activity['id']
)
# send the accept normally for a duplicate request
if not to_follow.manually_approves_followers:
status_builder.create_notification(
to_follow,
'FOLLOW',
related_user=user
)
outgoing.handle_accept(user, to_follow, relationship)
else:
status_builder.create_notification(
to_follow,
'FOLLOW_REQUEST',
related_user=user
)
manually_approves = relationship.user_object.manually_approves_followers
status_builder.create_notification(
relationship.user_object,
'FOLLOW_REQUEST' if manually_approves else 'FOLLOW',
related_user=relationship.user_subject
)
if not manually_approves:
outgoing.handle_accept(relationship)
@app.task
def handle_unfollow(activity):
''' unfollow a local user '''
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'])
# raises models.User.DoesNotExist
@ -161,7 +157,7 @@ def handle_follow_accept(activity):
# figure out who they want to follow
requester = models.User.objects.get(remote_id=activity['object']['actor'])
# figure out who they are
accepter = get_or_create_remote_user(activity['actor'])
accepter = activitypub.resolve_remote_id(models.User, activity['actor'])
try:
request = models.UserFollowRequest.objects.get(
@ -178,34 +174,32 @@ def handle_follow_accept(activity):
def handle_follow_reject(activity):
''' someone is rejecting a follow request '''
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(
user_subject=requester,
user_object=rejecter
)
request.delete()
#raises models.UserFollowRequest.DoesNotExist:
#raises models.UserFollowRequest.DoesNotExist
@app.task
def handle_create(activity):
''' someone did something, good on them '''
if activity['object'].get('type') not in \
['Note', 'Comment', 'Quotation', 'Review']:
# if it's an article or unknown type, ignore it
# deduplicate incoming activities
status_id = activity['object']['id']
if models.Status.objects.filter(remote_id=status_id).count():
return
user = get_or_create_remote_user(activity['actor'])
if user.local:
# we really oughtn't even be sending in this case
serializer = activitypub.activity_objects[activity['type']]
status = serializer(**activity)
try:
model = models.activity_models[activity.type]
except KeyError:
# not a type of status we are prepared to deserialize
return
# render the json into an activity object
serializer = activitypub.activity_objects[activity['object']['type']]
activity = serializer(**activity['object'])
# ignore notes that aren't replies to known statuses
if activity.type == 'Note':
reply = models.Status.objects.filter(
remote_id=activity.inReplyTo
@ -213,9 +207,7 @@ def handle_create(activity):
if not reply:
return
model = models.activity_models[activity.type]
status = activity.to_model(model)
activity.to_model(model)
# create a notification if this is a reply
if status.reply_parent and status.reply_parent.user.local:
status_builder.create_notification(
@ -226,72 +218,105 @@ def handle_create(activity):
)
@app.task
def handle_delete_status(activity):
''' remove a status '''
try:
status_id = activity['object']['id']
except TypeError:
# this isn't a great fix, because you hit this when mastadon
# is trying to delete a user.
return
try:
status = models.Status.objects.select_subclasses().get(
remote_id=status_id
)
except models.Status.DoesNotExist:
return
status_builder.delete_status(status)
@app.task
def handle_favorite(activity):
''' approval of your good good post '''
fav = activitypub.Like(**activity['object'])
# raises ValueError in to_model if a foreign key could not be resolved in
fav = activitypub.Like(**activity)
liker = get_or_create_remote_user(activity['actor'])
if liker.local:
fav = fav.to_model(models.Favorite)
if fav.user.local:
return
status = fav.to_model(models.Favorite)
status_builder.create_notification(
status.user,
fav.status.user,
'FAVORITE',
related_user=liker,
related_status=status,
related_user=fav.user,
related_status=fav.status,
)
@app.task
def handle_unfavorite(activity):
''' approval of your good good post '''
like = activitypub.Like(**activity['object'])
fav = models.Favorite.objects.filter(remote_id=like.id).first()
fav.delete()
like = models.Favorite.objects.filter(
remote_id=activity['object']['id']
).first()
if not like:
return
like.delete()
@app.task
def handle_boost(activity):
''' someone gave us a boost! '''
status_id = activity['object'].split('/')[-1]
status = models.Status.objects.get(id=status_id)
booster = get_or_create_remote_user(activity['actor'])
try:
boost = activitypub.Boost(**activity).to_model(models.Boost)
except activitypub.ActivitySerializerError:
# this probably just means we tried to boost an unknown status
return
if not booster.local:
status_builder.create_boost_from_activity(booster, activity)
status_builder.create_notification(
status.user,
'BOOST',
related_user=booster,
related_status=status,
)
if not boost.user.local:
status_builder.create_notification(
boost.boosted_status.user,
'BOOST',
related_user=boost.user,
related_status=boost.boosted_status,
)
@app.task
def handle_tag(activity):
''' someone is tagging a book '''
user = get_or_create_remote_user(activity['actor'])
if not user.local:
book = activity['target']['id']
status_builder.create_tag(user, book, activity['object']['name'])
def handle_unboost(activity):
''' someone gave us a boost! '''
boost = models.Boost.objects.filter(
remote_id=activity['object']['id']
).first()
if boost:
boost.delete()
@app.task
def handle_add(activity):
''' putting a book on a shelf '''
#this is janky as heck but I haven't thought of a better solution
try:
activitypub.AddBook(**activity).to_model(models.ShelfBook)
except activitypub.ActivitySerializerError:
activitypub.AddBook(**activity).to_model(models.Tag)
@app.task
def handle_update_user(activity):
''' receive an updated user Person activity object '''
try:
user = models.User.objects.get(remote_id=activity['object']['id'])
except models.User.DoesNotExist:
# who is this person? who cares
return
activitypub.Person(
**activity['object']
).to_model(models.User, instance=user)
# model save() happens in the to_model function
@app.task
def handle_update_book(activity):
''' a remote instance changed a book (Document) '''
document = activity['object']
# check if we have their copy and care about their updates
book = models.Book.objects.select_subclasses().filter(
remote_id=document['url'],
sync=True,
).first()
if not book:
return
books_manager.update_book(book, data=document)
activitypub.Edition(**activity['object']).to_model(models.Edition)

View file

@ -0,0 +1,104 @@
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from bookwyrm.models import Connector, SiteSettings, User
from bookwyrm.settings import DOMAIN
def init_groups():
groups = ['admin', 'moderator', 'editor']
for group in groups:
Group.objects.create(name=group)
def init_permissions():
permissions = [{
'codename': 'edit_instance_settings',
'name': 'change the instance info',
'groups': ['admin',]
}, {
'codename': 'set_user_group',
'name': 'change what group a user is in',
'groups': ['admin', 'moderator']
}, {
'codename': 'control_federation',
'name': 'control who to federate with',
'groups': ['admin', 'moderator']
}, {
'codename': 'create_invites',
'name': 'issue invitations to join',
'groups': ['admin', 'moderator']
}, {
'codename': 'moderate_user',
'name': 'deactivate or silence a user',
'groups': ['admin', 'moderator']
}, {
'codename': 'moderate_post',
'name': 'delete other users\' posts',
'groups': ['admin', 'moderator']
}, {
'codename': 'edit_book',
'name': 'edit book info',
'groups': ['admin', 'moderator', 'editor']
}]
content_type = ContentType.objects.get_for_model(User)
for permission in permissions:
permission_obj = Permission.objects.create(
codename=permission['codename'],
name=permission['name'],
content_type=content_type,
)
# add the permission to the appropriate groups
for group_name in permission['groups']:
Group.objects.get(name=group_name).permissions.add(permission_obj)
# while the groups and permissions shouldn't be changed because the code
# depends on them, what permissions go with what groups should be editable
def init_connectors():
Connector.objects.create(
identifier=DOMAIN,
name='Local',
local=True,
connector_file='self_connector',
base_url='https://%s' % DOMAIN,
books_url='https://%s/book' % DOMAIN,
covers_url='https://%s/images/covers' % DOMAIN,
search_url='https://%s/search?q=' % DOMAIN,
priority=1,
)
Connector.objects.create(
identifier='bookwyrm.social',
name='BookWyrm dot Social',
connector_file='bookwyrm_connector',
base_url='https://bookwyrm.social',
books_url='https://bookwyrm.social/book',
covers_url='https://bookwyrm.social/images/covers',
search_url='https://bookwyrm.social/search?q=',
priority=2,
)
Connector.objects.create(
identifier='openlibrary.org',
name='OpenLibrary',
connector_file='openlibrary',
base_url='https://openlibrary.org',
books_url='https://openlibrary.org',
covers_url='https://covers.openlibrary.org',
search_url='https://openlibrary.org/search?q=',
priority=3,
)
def init_settings():
SiteSettings.objects.create()
class Command(BaseCommand):
help = 'Initializes the database with starter data'
def handle(self, *args, **options):
init_groups()
init_permissions()
init_connectors()
init_settings()

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.3 on 2020-02-21 17:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0005_auto_20200221_1645'),
]
operations = [
migrations.AlterField(
model_name='tag',
name='identifier',
field=models.CharField(max_length=100),
),
]

File diff suppressed because it is too large Load diff

View file

@ -1,17 +0,0 @@
# Generated by Django 3.0.3 on 2020-02-23 09:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0006_auto_20200221_1702'),
]
operations = [
migrations.AddConstraint(
model_name='userrelationship',
constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='followers_unique'),
),
]

View file

@ -1,4 +1,4 @@
# Generated by Django 3.0.3 on 2020-06-02 15:46
# Generated by Django 3.0.7 on 2020-11-03 00:14
from django.conf import settings
from django.db import migrations, models
@ -8,14 +8,13 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0043_siteinvite'),
('bookwyrm', '0006_auto_20200221_1702_squashed_0064_merge_20201101_1913'),
]
operations = [
migrations.AddField(
migrations.AlterField(
model_name='siteinvite',
name='user',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
preserve_default=False,
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 3.0.3 on 2020-02-24 15:04
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0007_auto_20200223_0902'),
]
operations = [
migrations.AlterField(
model_name='user',
name='followers',
field=models.ManyToManyField(related_name='following', through='bookwyrm.UserRelationship', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -0,0 +1,35 @@
# Generated by Django 3.0.7 on 2020-11-04 18:15
from django.db import migrations, models
import django.db.models.deletion
def set_default_edition(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
works = app_registry.get_model('bookwyrm', 'Work').objects.using(db_alias)
editions = app_registry.get_model('bookwyrm', 'Edition').objects.using(db_alias)
for work in works:
ed = editions.filter(parent_work=work, default=True).first()
if not ed:
ed = editions.filter(parent_work=work).first()
work.default_edition = ed
work.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0007_auto_20201103_0014'),
]
operations = [
migrations.AddField(
model_name='work',
name='default_edition',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.RunPython(set_default_edition),
migrations.RemoveField(
model_name='edition',
name='default',
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-11-10 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0008_work_default_edition'),
]
operations = [
migrations.AddField(
model_name='shelf',
name='privacy',
field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-07 00:28
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0008_auto_20200224_1504'),
]
operations = [
migrations.AddField(
model_name='status',
name='published_date',
field=models.DateTimeField(default=datetime.datetime.now),
),
]

View file

@ -1,190 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-07 06:55
import datetime
from django.db import migrations, models
import django.db.models.deletion
import bookwyrm.utils.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0009_status_published_date'),
]
operations = [
migrations.CreateModel(
name='Edition',
fields=[
('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')),
('isbn', models.CharField(max_length=255, null=True, unique=True)),
('oclc_number', models.CharField(max_length=255, null=True, unique=True)),
('pages', models.IntegerField(null=True)),
],
options={
'abstract': False,
},
bases=('bookwyrm.book',),
),
migrations.CreateModel(
name='Work',
fields=[
('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Book')),
('lccn', models.CharField(max_length=255, null=True, unique=True)),
],
options={
'abstract': False,
},
bases=('bookwyrm.book',),
),
migrations.RemoveField(
model_name='author',
name='data',
),
migrations.RemoveField(
model_name='book',
name='added_by',
),
migrations.RemoveField(
model_name='book',
name='data',
),
migrations.AddField(
model_name='author',
name='aliases',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, size=None),
),
migrations.AddField(
model_name='author',
name='bio',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='author',
name='born',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='author',
name='died',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='author',
name='first_name',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='author',
name='last_name',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='author',
name='name',
field=models.CharField(default='Unknown', max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name='author',
name='wikipedia_link',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='description',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='book',
name='first_published_date',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='book',
name='language',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='last_sync_date',
field=models.DateTimeField(default=datetime.datetime.now),
),
migrations.AddField(
model_name='book',
name='librarything_key',
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AddField(
model_name='book',
name='local_edits',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='book',
name='local_key',
field=models.CharField(default=uuid.uuid4, max_length=255, unique=True),
),
migrations.AddField(
model_name='book',
name='misc_identifiers',
field=bookwyrm.utils.fields.JSONField(null=True),
),
migrations.AddField(
model_name='book',
name='origin',
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AddField(
model_name='book',
name='published_date',
field=models.DateTimeField(null=True),
),
migrations.AddField(
model_name='book',
name='series',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='series_number',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='sort_title',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='subtitle',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='book',
name='sync',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='book',
name='title',
field=models.CharField(default='Unknown', max_length=255),
preserve_default=False,
),
migrations.AlterField(
model_name='author',
name='openlibrary_key',
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AlterField(
model_name='book',
name='openlibrary_key',
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AddField(
model_name='book',
name='parent_work',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'),
),
]

View file

@ -1,4 +1,4 @@
# Generated by Django 3.0.3 on 2020-03-09 20:09
# Generated by Django 3.0.7 on 2020-11-13 15:54
from django.db import migrations, models
@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0012_auto_20200308_1625'),
('bookwyrm', '0009_shelf_privacy'),
]
operations = [
migrations.AddField(
model_name='user',
name='manually_approves_followers',
model_name='importjob',
name='retry',
field=models.BooleanField(default=False),
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 3.0.7 on 2020-11-13 17:27
from django.db import migrations, models
def set_origin_id(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
books = app_registry.get_model('bookwyrm', 'Book').objects.using(db_alias)
for book in books:
book.origin_id = book.remote_id
# the remote_id will be set automatically
book.remote_id = None
book.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0010_importjob_retry'),
]
operations = [
migrations.AddField(
model_name='author',
name='origin_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='origin_id',
field=models.CharField(max_length=255, null=True),
),
migrations.RunPython(set_origin_id),
]

View file

@ -1,32 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-07 22:23
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0010_auto_20200307_0655'),
]
operations = [
migrations.CreateModel(
name='Notification',
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)),
('read', models.BooleanField(default=False)),
('notification_type', models.CharField(max_length=255)),
('related_book', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
('related_status', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status')),
('related_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='related_user', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 3.0.7 on 2020-11-24 19:39
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0011_auto_20201113_1727'),
]
operations = [
migrations.CreateModel(
name='Attachment',
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)),
('image', models.ImageField(blank=True, null=True, upload_to='status/')),
('caption', models.TextField(blank=True, null=True)),
('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status')),
],
options={
'abstract': False,
},
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-08 16:25
from django.db import migrations, models
import bookwyrm.utils.fields
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0011_notification'),
]
operations = [
migrations.AlterField(
model_name='author',
name='aliases',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.7 on 2020-11-24 21:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0012_attachment'),
]
operations = [
migrations.AlterField(
model_name='book',
name='origin_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 3.0.7 on 2020-11-28 01:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0013_book_origin_id'),
]
operations = [
migrations.RenameModel(
old_name='Attachment',
new_name='Image',
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-10 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0013_user_manually_approves_followers'),
]
operations = [
migrations.AddField(
model_name='status',
name='remote_id',
field=models.CharField(max_length=255, null=True, unique=True),
),
]

View file

@ -1,115 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-11 12:12
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0014_status_remote_id'),
]
operations = [
migrations.CreateModel(
name='UserBlocks',
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)),
('relationship_id', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='UserFollowRequest',
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)),
('relationship_id', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='UserFollows',
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)),
('relationship_id', models.CharField(max_length=100)),
],
options={
'abstract': False,
},
),
migrations.RemoveField(
model_name='user',
name='followers',
),
migrations.DeleteModel(
name='UserRelationship',
),
migrations.AddField(
model_name='userfollows',
name='user_object',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_object', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userfollows',
name='user_subject',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollows_user_subject', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userfollowrequest',
name='user_object',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_object', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userfollowrequest',
name='user_subject',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userfollowrequest_user_subject', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userblocks',
name='user_object',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_object', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='userblocks',
name='user_subject',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='userblocks_user_subject', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='blocks',
field=models.ManyToManyField(related_name='blocked_by', through='bookwyrm.UserBlocks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='follow_requests',
field=models.ManyToManyField(related_name='follower_requests', through='bookwyrm.UserFollowRequest', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='following',
field=models.ManyToManyField(related_name='followers', through='bookwyrm.UserFollows', to=settings.AUTH_USER_MODEL),
),
migrations.AddConstraint(
model_name='userfollows',
constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollows_unique'),
),
migrations.AddConstraint(
model_name='userfollowrequest',
constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userfollowrequest_unique'),
),
migrations.AddConstraint(
model_name='userblocks',
constraint=models.UniqueConstraint(fields=('user_subject', 'user_object'), name='userblocks_unique'),
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 3.0.7 on 2020-11-28 03:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0014_auto_20201128_0118'),
]
operations = [
migrations.AlterField(
model_name='image',
name='status',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status'),
),
]

View file

@ -1,22 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-13 13:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0015_auto_20200311_1212'),
]
operations = [
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request')], max_length=255),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST']), name='notification_type_valid'),
),
]

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,28 @@
# Generated by Django 3.0.7 on 2020-12-11 20:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0015_auto_20201128_0349'),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='admin_email',
field=models.EmailField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='sitesettings',
name='support_link',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='sitesettings',
name='support_title',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View file

@ -1,26 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-14 21:52
from django.db import migrations, models
import django.db.models.expressions
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0016_auto_20200313_1337'),
]
operations = [
migrations.AddConstraint(
model_name='userblocks',
constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userblocks_no_self'),
),
migrations.AddConstraint(
model_name='userfollowrequest',
constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollowrequest_no_self'),
),
migrations.AddConstraint(
model_name='userfollows',
constraint=models.CheckConstraint(check=models.Q(_negated=True, user_subject=django.db.models.expressions.F('user_object')), name='userfollows_no_self'),
),
]

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

@ -1,18 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-21 21:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0017_auto_20200314_2152'),
]
operations = [
migrations.AddField(
model_name='favorite',
name='remote_id',
field=models.CharField(max_length=255, null=True, unique=True),
),
]

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

@ -1,26 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-21 22:43
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0018_favorite_remote_id'),
]
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
('name', models.CharField(max_length=255)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
],
options={
'abstract': False,
},
bases=('bookwyrm.status',),
),
]

View file

@ -1,58 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-27 23:35
from django.db import migrations, models
import django.db.models.deletion
import bookwyrm.models.book
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0019_comment'),
]
operations = [
migrations.CreateModel(
name='Connector',
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)),
('identifier', models.CharField(max_length=255, unique=True)),
('connector_file', models.CharField(choices=[('openlibrary', 'Openlibrary'), ('bookwyrm', 'BookWyrm')], default='openlibrary', max_length=255)),
('is_self', models.BooleanField(default=False)),
('api_key', models.CharField(max_length=255, null=True)),
('base_url', models.CharField(max_length=255)),
('covers_url', models.CharField(max_length=255)),
('search_url', models.CharField(max_length=255, null=True)),
('key_name', models.CharField(max_length=255)),
('politeness_delay', models.IntegerField(null=True)),
('max_query_count', models.IntegerField(null=True)),
('query_count', models.IntegerField(default=0)),
('query_count_expiry', models.DateTimeField(auto_now_add=True)),
],
),
migrations.RenameField(
model_name='book',
old_name='local_key',
new_name='fedireads_key',
),
migrations.RenameField(
model_name='book',
old_name='origin',
new_name='source_url',
),
migrations.RemoveField(
model_name='book',
name='local_edits',
),
migrations.AddConstraint(
model_name='connector',
constraint=models.CheckConstraint(check=models.Q(connector_file__in=bookwyrm.models.connector.ConnectorFiles), name='connector_file_valid'),
),
migrations.AddField(
model_name='book',
name='connector',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Connector'),
),
]

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

@ -1,44 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-28 04:28
from django.db import migrations, models
import bookwyrm.utils.fields
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0020_auto_20200327_2335'),
]
operations = [
migrations.AddField(
model_name='book',
name='goodreads_key',
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AddField(
model_name='book',
name='subject_places',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AddField(
model_name='book',
name='subjects',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AddField(
model_name='edition',
name='physical_format',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='edition',
name='publishers',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AlterField(
model_name='connector',
name='connector_file',
field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255),
),
]

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

@ -1,27 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-28 20:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0021_auto_20200328_0428'),
]
operations = [
migrations.RemoveField(
model_name='connector',
name='is_self',
),
migrations.AddField(
model_name='author',
name='fedireads_key',
field=models.CharField(max_length=255, null=True, unique=True),
),
migrations.AlterField(
model_name='connector',
name='connector_file',
field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], default='openlibrary', max_length=255),
),
]

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

@ -1,114 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-28 22:03
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0022_auto_20200328_2001'),
]
operations = [
migrations.AddField(
model_name='book',
name='sync_cover',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='author',
name='born',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='author',
name='died',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='author',
name='fedireads_key',
field=models.CharField(default=uuid.uuid4, max_length=255, unique=True),
),
migrations.AlterField(
model_name='author',
name='first_name',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='author',
name='last_name',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='author',
name='openlibrary_key',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='first_published_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='goodreads_key',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='language',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='librarything_key',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='openlibrary_key',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='published_date',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='sort_title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='book',
name='subtitle',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='isbn',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='oclc_number',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='edition',
name='pages',
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name='edition',
name='physical_format',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name='work',
name='lccn',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-29 22:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0023_auto_20200328_2203'),
]
operations = [
migrations.AddField(
model_name='federatedserver',
name='application_version',
field=models.CharField(max_length=255, null=True),
),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-30 00:37
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0024_federatedserver_application_version'),
]
operations = [
migrations.AlterField(
model_name='book',
name='last_sync_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AlterField(
model_name='status',
name='published_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View file

@ -1,42 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-30 14:56
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0025_auto_20200330_0037'),
]
operations = [
migrations.CreateModel(
name='Boost',
fields=[
('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
],
options={
'abstract': False,
},
bases=('bookwyrm.status',),
),
migrations.RemoveConstraint(
model_name='notification',
name='notification_type_valid',
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost')], max_length=255),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST']), name='notification_type_valid'),
),
migrations.AddField(
model_name='boost',
name='boosted_status',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='boosters', to='bookwyrm.Status'),
),
]

View file

@ -1,82 +0,0 @@
# Generated by Django 3.0.3 on 2020-03-30 22:32
from django.db import migrations, models
import django.db.models.deletion
import bookwyrm.utils.fields
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0026_auto_20200330_1456'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='language',
),
migrations.RemoveField(
model_name='book',
name='parent_work',
),
migrations.RemoveField(
model_name='book',
name='shelves',
),
migrations.AddField(
model_name='book',
name='languages',
field=bookwyrm.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AddField(
model_name='edition',
name='default',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='edition',
name='parent_work',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Work'),
),
migrations.AddField(
model_name='edition',
name='shelves',
field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Shelf'),
),
migrations.AlterField(
model_name='comment',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='notification',
name='related_book',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='review',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='shelf',
name='books',
field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='shelfbook',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='status',
name='mention_books',
field=models.ManyToManyField(related_name='mention_book', to='bookwyrm.Edition'),
),
migrations.AlterField(
model_name='tag',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-01 18:24
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0027_auto_20200330_2232'),
]
operations = [
migrations.RemoveField(
model_name='comment',
name='name',
),
migrations.AlterField(
model_name='review',
name='rating',
field=models.IntegerField(blank=True, default=None, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)]),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-03 18:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0028_auto_20200401_1824'),
]
operations = [
migrations.AlterField(
model_name='review',
name='name',
field=models.CharField(max_length=255, null=True),
),
]

View file

@ -1,26 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-07 00:51
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0029_auto_20200403_1835'),
]
operations = [
migrations.CreateModel(
name='Quotation',
fields=[
('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
('quote', models.TextField()),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
],
options={
'abstract': False,
},
bases=('bookwyrm.status',),
),
]

View file

@ -1,31 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-15 12:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0030_quotation'),
]
operations = [
migrations.CreateModel(
name='ReadThrough',
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)),
('pages_read', models.IntegerField(blank=True, null=True)),
('start_date', models.DateTimeField(blank=True, null=True)),
('finish_date', models.DateTimeField(blank=True, null=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View file

@ -1,60 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-21 13:47
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import bookwyrm.utils.fields
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0031_readthrough'),
]
operations = [
migrations.CreateModel(
name='ImportItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', bookwyrm.utils.fields.JSONField()),
],
),
migrations.CreateModel(
name='ImportJob',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(default=django.utils.timezone.now)),
('task_id', models.CharField(max_length=100, null=True)),
],
),
migrations.RemoveConstraint(
model_name='notification',
name='notification_type_valid',
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT_RESULT', 'Import Result')], max_length=255),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT_RESULT']), name='notification_type_valid'),
),
migrations.AddField(
model_name='importjob',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='importitem',
name='book',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookwyrm.Book'),
),
migrations.AddField(
model_name='importitem',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='bookwyrm.ImportJob'),
),
]

View file

@ -1,43 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-22 12:49
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0032_auto_20200421_1347'),
]
operations = [
migrations.RemoveConstraint(
model_name='notification',
name='notification_type_valid',
),
migrations.AddField(
model_name='importitem',
name='fail_reason',
field=models.TextField(null=True),
),
migrations.AddField(
model_name='importitem',
name='index',
field=models.IntegerField(default=1),
preserve_default=False,
),
migrations.AddField(
model_name='notification',
name='related_import',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ImportJob'),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import')], max_length=255),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT']), name='notification_type_valid'),
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-22 13:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0033_auto_20200422_1249'),
]
operations = [
migrations.AddField(
model_name='importjob',
name='import_status',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Status'),
),
]

View file

@ -1,33 +0,0 @@
# Generated by Django 3.0.3 on 2020-04-29 17:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0034_importjob_import_status'),
]
operations = [
migrations.RenameField(
model_name='edition',
old_name='isbn',
new_name='isbn_13',
),
migrations.AddField(
model_name='book',
name='author_text',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='edition',
name='asin',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='edition',
name='isbn_10',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -1,39 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-03 20:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0035_auto_20200429_1708'),
]
operations = [
migrations.AddField(
model_name='connector',
name='books_url',
field=models.CharField(default='https://openlibrary.org', max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name='connector',
name='local',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='connector',
name='name',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='connector',
name='priority',
field=models.IntegerField(default=2),
),
migrations.AlterField(
model_name='connector',
name='connector_file',
field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('fedireads_connector', 'Fedireads Connector')], max_length=255),
),
]

View file

@ -1,41 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-04 01:54
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0036_auto_20200503_2007'),
]
operations = [
migrations.RemoveField(
model_name='author',
name='fedireads_key',
),
migrations.RemoveField(
model_name='book',
name='fedireads_key',
),
migrations.RemoveField(
model_name='book',
name='source_url',
),
migrations.AddField(
model_name='author',
name='last_sync_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='author',
name='sync',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='book',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-09 19:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0037_auto_20200504_0154'),
]
operations = [
migrations.AddField(
model_name='author',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
]

View file

@ -1,21 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-10 23:42
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0038_author_remote_id'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='misc_identifiers',
),
migrations.RemoveField(
model_name='connector',
name='key_name',
),
]

View file

@ -1,77 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-13 01:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0039_auto_20200510_2342'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='actor',
),
migrations.AddField(
model_name='connector',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='federatedserver',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='notification',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='readthrough',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='shelf',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='shelfbook',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='tag',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='userblocks',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='userfollowrequest',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='userfollows',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AlterField(
model_name='favorite',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
migrations.AlterField(
model_name='status',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-13 02:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0040_auto_20200513_0153'),
]
operations = [
migrations.AddField(
model_name='user',
name='remote_id',
field=models.CharField(max_length=255, null=True, unique=True),
),
]

View file

@ -1,21 +0,0 @@
# Generated by Django 3.0.3 on 2020-05-24 03:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0041_user_remote_id'),
]
operations = [
migrations.RemoveField(
model_name='status',
name='activity_type',
),
migrations.RemoveField(
model_name='status',
name='status_type',
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 3.0.3 on 2020-06-01 18:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0041_user_remote_id'),
]
operations = [
migrations.CreateModel(
name='SiteSettings',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(default='wyrms.cthulahoops.org', max_length=100)),
('instance_description', models.TextField(default='This instance has no description.')),
('code_of_conduct', models.TextField(default='Add a code of conduct here.')),
('allow_registration', models.BooleanField(default=True)),
],
),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 3.0.3 on 2020-06-01 21:31
from django.db import migrations, models
import bookwyrm.models.site
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0042_sitesettings'),
]
operations = [
migrations.CreateModel(
name='SiteInvite',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)),
('expiry', models.DateTimeField(blank=True, null=True)),
('use_limit', models.IntegerField(blank=True, null=True)),
('times_used', models.IntegerField(default=0)),
],
),
]

View file

@ -1,14 +0,0 @@
# Generated by Django 3.0.7 on 2020-08-10 20:10
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0044_siteinvite_user'),
('bookwyrm', '0042_auto_20200524_0346'),
]
operations = [
]

View file

@ -1,28 +0,0 @@
# Generated by Django 3.0.7 on 2020-09-21 15:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0045_merge_20200810_2010'),
]
operations = [
migrations.RenameField(
model_name='user',
old_name='fedireads_user',
new_name='bookwyrm_user',
),
migrations.AlterField(
model_name='connector',
name='connector_file',
field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'BookWyrm Connector')], max_length=255),
),
migrations.AlterField(
model_name='sitesettings',
name='name',
field=models.CharField(default='1d8390fd.ngrok.io', max_length=100),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.7 on 2020-09-28 23:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0046_auto_20200921_1509'),
]
operations = [
migrations.AlterField(
model_name='connector',
name='connector_file',
field=models.CharField(choices=[('openlibrary', 'Openlibrary'), ('self_connector', 'Self Connector'), ('bookwyrm_connector', 'Bookwyrm Connector')], max_length=255),
),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 3.0.7 on 2020-09-29 00:22
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0047_auto_20200928_2312'),
]
operations = [
migrations.CreateModel(
name='GeneratedStatus',
fields=[
('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='bookwyrm.Status')),
],
options={
'abstract': False,
},
bases=('bookwyrm.status',),
),
]

View file

@ -1,25 +0,0 @@
# Generated by Django 3.0.7 on 2020-10-02 19:43
import bookwyrm.models.site
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0048_generatednote'),
]
operations = [
migrations.CreateModel(
name='PasswordReset',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(default=bookwyrm.models.site.new_access_code, max_length=32)),
('expiry', models.DateTimeField(default=bookwyrm.models.site.get_passowrd_reset_expiry)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.7 on 2020-10-02 21:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0049_passwordreset'),
]
operations = [
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(max_length=254, unique=True),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.7 on 2020-10-05 21:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0050_auto_20201002_2156'),
]
operations = [
migrations.AlterField(
model_name='sitesettings',
name='name',
field=models.CharField(default='BookWyrm', max_length=100),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 3.0.7 on 2020-10-05 21:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0051_auto_20201005_2142'),
]
operations = [
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(blank=True, max_length=254, verbose_name='email address'),
),
]

View file

@ -2,19 +2,31 @@
import inspect
import sys
from .book import Book, Work, Edition, Author
from .book import Book, Work, Edition
from .author import Author
from .connector import Connector
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .shelf import Shelf, ShelfBook
from .status import Status, GeneratedStatus, Review, Comment, Quotation
from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough
from .tag import Tag
from .user import User
from .attachment import Image
from .tag import Tag, UserTag
from .user import User, KeyPair
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite, PasswordReset
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[0]: c[1].activity_serializer for c in cls_members \
if hasattr(c[1], 'activity_serializer')}
activity_models = {c[1].activity_serializer.__name__: c[1] \
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

@ -0,0 +1,30 @@
''' media that is posted in the app '''
from django.db import models
from bookwyrm import activitypub
from .base_model import ActivitypubMixin
from .base_model import BookWyrmModel
from . import fields
class Attachment(ActivitypubMixin, BookWyrmModel):
''' an image (or, in the future, video etc) associated with a status '''
status = models.ForeignKey(
'Status',
on_delete=models.CASCADE,
related_name='attachments',
null=True
)
reverse_unfurl = True
class Meta:
''' one day we'll have other types of attachments besides images '''
abstract = True
class Image(Attachment):
''' an image attachment '''
image = fields.ImageField(
upload_to='status/', null=True, blank=True, activitypub_field='url')
caption = fields.TextField(null=True, blank=True, activitypub_field='name')
activity_serializer = activitypub.Image

43
bookwyrm/models/author.py Normal file
View file

@ -0,0 +1,43 @@
''' database schema for info about authors '''
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class Author(ActivitypubMixin, BookWyrmModel):
''' basic biographic info '''
origin_id = models.CharField(max_length=255, null=True)
openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = fields.CharField(max_length=255, blank=True, null=True, deduplication_field=True)
# idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)
name = fields.CharField(max_length=255)
aliases = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = fields.TextField(null=True, blank=True)
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if self.id and not self.remote_id:
self.remote_id = self.get_remote_id()
if not self.id:
self.origin_id = self.remote_id
self.remote_id = None
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_serializer = activitypub.Author

View file

@ -1,24 +1,34 @@
''' base model with default fields '''
from base64 import b64encode
from dataclasses import dataclass
from typing import Callable
from functools import reduce
import operator
from uuid import uuid4
from urllib.parse import urlencode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from django.core.paginator import Paginator
from django.db import models
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
from .fields import RemoteIdField
PrivacyLevels = models.TextChoices('Privacy', [
'public',
'unlisted',
'followers',
'direct'
])
class BookWyrmModel(models.Model):
''' shared fields '''
created_date = models.DateTimeField(auto_now_add=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):
''' generate a url that resolves to the local object '''
@ -43,40 +53,99 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
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:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
reverse_unfurl = False
def to_activity(self, pure=False):
''' convert from a model to an activity '''
if pure:
mappings = self.pure_activity_mappings
else:
mappings = self.activity_mappings
@classmethod
def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db '''
return cls.find_existing({'id': remote_id})
fields = {}
for mapping in mappings:
if not hasattr(self, mapping.model_key) or not mapping.activity_key:
@classmethod
def find_existing(cls, data):
''' compare data to fields that can be used for deduplation.
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
value = getattr(self, mapping.model_key)
if hasattr(value, 'remote_id'):
value = value.remote_id
fields[mapping.activity_key] = mapping.activity_formatter(value)
if pure:
return self.pure_activity_serializer(
**fields
).serialize()
return self.activity_serializer(
**fields
).serialize()
value = data.get(field.activitypub_field)
if not value:
continue
filters.append({field.name: value})
if hasattr(cls, 'origin_id') and 'id' in data:
# 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_create_activity(self, user, pure=False):
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:
activity[key] = value
if hasattr(self, 'serialize_reverse_fields'):
# for example, editions of a work
for model_field_name, activity_field_name in \
self.serialize_reverse_fields:
related_field = getattr(self, model_field_name)
activity[activity_field_name] = \
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):
''' 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']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity'
@ -90,16 +159,27 @@ class ActivitypubMixin:
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
to=activity_object['to'],
cc=activity_object['cc'],
object=activity_object,
signature=signature,
).serialize()
def to_delete_activity(self, user):
''' notice of deletion '''
return activitypub.Delete(
id=self.remote_id + '/activity',
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity(),
).serialize()
def to_update_activity(self, user):
''' 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(
id=activity_id,
actor=user.remote_id,
@ -111,10 +191,10 @@ class ActivitypubMixin:
def to_undo_activity(self, user):
''' undo an action '''
return activitypub.Undo(
id='%s#undo' % user.remote_id,
id='%s#undo' % self.remote_id,
actor=user.remote_id,
object=self.to_activity()
)
).serialize()
class OrderedCollectionPageMixin(ActivitypubMixin):
@ -125,77 +205,53 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
''' this can be overriden if there's a special remote id, ie outbox '''
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, \
remote_id=None, page=False, **kwargs):
''' an ordered collection of whatevers '''
remote_id = remote_id or self.remote_id
if page:
return self.to_ordered_collection_page(
return to_ordered_collection_page(
queryset, remote_id, **kwargs)
name = ''
if hasattr(self, 'name'):
name = self.name
name = self.name if hasattr(self, 'name') else None
owner = self.user.remote_id if hasattr(self, 'user') else ''
size = queryset.count()
paginated = Paginator(queryset, PAGE_LENGTH)
return activitypub.OrderedCollection(
id=remote_id,
totalItems=size,
totalItems=paginated.count,
name=name,
first='%s%s' % (remote_id, self.page()),
last='%s%s' % (remote_id, self.page(min_id=0))
owner=owner,
first='%s?page=1' % remote_id,
last='%s?page=%d' % (remote_id, paginated.num_pages)
).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):
''' extends activitypub models to work as ordered collections '''
@property
@ -208,12 +264,3 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
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

View file

@ -1,22 +1,27 @@
''' database schema for books and shelves '''
import re
from django.db import models
from django.utils import timezone
from django.utils.http import http_date
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.utils.fields import ArrayField
from .base_model import ActivityMapping, ActivitypubMixin, BookWyrmModel
from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from . import fields
class Book(ActivitypubMixin, BookWyrmModel):
''' a generic book, which can mean either an edition or a work '''
origin_id = models.CharField(max_length=255, null=True, blank=True)
# these identifiers apply to both works and editions
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
librarything_key = models.CharField(max_length=255, blank=True, null=True)
goodreads_key = models.CharField(max_length=255, blank=True, null=True)
openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=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
sync = models.BooleanField(default=True)
@ -28,97 +33,48 @@ class Book(ActivitypubMixin, BookWyrmModel):
# TODO: edit history
# book/work metadata
title = models.CharField(max_length=255)
sort_title = models.CharField(max_length=255, blank=True, null=True)
subtitle = models.CharField(max_length=255, blank=True, null=True)
description = models.TextField(blank=True, null=True)
languages = ArrayField(
title = fields.CharField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True)
subtitle = fields.CharField(max_length=255, blank=True, null=True)
description = fields.TextField(blank=True, null=True)
languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
series = models.CharField(max_length=255, blank=True, null=True)
series_number = models.CharField(max_length=255, blank=True, null=True)
subjects = ArrayField(
models.CharField(max_length=255), blank=True, default=list
series = fields.CharField(max_length=255, blank=True, null=True)
series_number = fields.CharField(max_length=255, blank=True, null=True)
subjects = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
)
subject_places = ArrayField(
models.CharField(max_length=255), blank=True, default=list
subject_places = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
)
# 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
author_text = models.CharField(max_length=255, blank=True, null=True)
cover = models.ImageField(upload_to='covers/', blank=True, null=True)
first_published_date = models.DateTimeField(blank=True, null=True)
published_date = models.DateTimeField(blank=True, null=True)
cover = fields.ImageField(upload_to='covers/', blank=True, null=True)
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
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(
'first_published_date',
'first_published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping(
'published_date',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp()) if d else None
),
ActivityMapping('title', 'title'),
ActivityMapping('sort_title', 'sort_title'),
ActivityMapping('subtitle', 'subtitle'),
ActivityMapping('description', 'description'),
ActivityMapping('languages', 'languages'),
ActivityMapping('series', 'series'),
ActivityMapping('series_number', 'series_number'),
ActivityMapping('subjects', 'subjects'),
ActivityMapping('subject_places', 'subject_places'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('librarything_key', 'librarything_key'),
ActivityMapping('goodreads_key', 'goodreads_key'),
ActivityMapping('work', 'parent_work'),
ActivityMapping('isbn_10', 'isbn_10'),
ActivityMapping('isbn_13', 'isbn_13'),
ActivityMapping('oclc_number', 'oclc_number'),
ActivityMapping('asin', 'asin'),
ActivityMapping('pages', 'pages'),
ActivityMapping('physical_format', 'physical_format'),
ActivityMapping('publishers', 'publishers'),
ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'),
]
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works')
super().save(*args, **kwargs)
if self.id and not self.remote_id:
self.remote_id = self.get_remote_id()
if not self.id:
self.origin_id = self.remote_id
self.remote_id = None
return super().save(*args, **kwargs)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
return 'https://%s/book/%d' % (DOMAIN, self.id)
@property
def local_id(self):
''' when a book is ingested from an outside source, it becomes local to
an instance, so it needs a local url for federation. but it still needs
the remote_id for easier deduplication and, if appropriate, to sync with
the remote canonical copy '''
return 'https://%s/book/%d' % (DOMAIN, self.id)
def __repr__(self):
return "<{} key={!r} title={!r}>".format(
self.__class__,
@ -127,41 +83,41 @@ class Book(ActivitypubMixin, BookWyrmModel):
)
class Work(Book):
class Work(OrderedCollectionPageMixin, Book):
''' a work (an abstract concept of a book that manifests in an edition) '''
# 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
default_edition = fields.ForeignKey(
'Edition',
on_delete=models.PROTECT,
null=True
)
@property
def editions_path(self):
''' it'd be nice to serialize the edition instead but, recursion '''
return self.remote_id + '/editions'
@property
def default_edition(self):
''' best-guess attempt at picking the default edition for this work '''
ed = Edition.objects.filter(parent_work=self, default=True).first()
if not ed:
ed = Edition.objects.filter(parent_work=self).first()
return ed
def get_default_edition(self):
''' in case the default edition is not set '''
return self.default_edition or self.editions.first()
activity_serializer = activitypub.Work
serialize_reverse_fields = [('editions', 'editions')]
deserialize_reverse_fields = [('editions', 'editions')]
class Edition(Book):
''' an edition of a book '''
# default -> this is what gets displayed for a work
default = models.BooleanField(default=False)
# these identifiers only apply to editions, not works
isbn_10 = models.CharField(max_length=255, blank=True, null=True)
isbn_13 = models.CharField(max_length=255, blank=True, null=True)
oclc_number = models.CharField(max_length=255, blank=True, null=True)
asin = models.CharField(max_length=255, blank=True, null=True)
pages = models.IntegerField(blank=True, null=True)
physical_format = models.CharField(max_length=255, blank=True, null=True)
publishers = ArrayField(
isbn_10 = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
isbn_13 = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
oclc_number = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
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
)
shelves = models.ManyToManyField(
@ -170,55 +126,61 @@ class Edition(Book):
through='ShelfBook',
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
name_field = 'title'
def save(self, *args, **kwargs):
''' calculate isbn 10/13 '''
if self.isbn_13 and self.isbn_13[:3] == '978' and not self.isbn_10:
self.isbn_10 = isbn_13_to_10(self.isbn_13)
if self.isbn_10 and not self.isbn_13:
self.isbn_13 = isbn_10_to_13(self.isbn_10)
return super().save(*args, **kwargs)
class Author(ActivitypubMixin, BookWyrmModel):
''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = models.CharField(max_length=255, blank=True, null=True)
# idk probably other keys would be useful here?
born = models.DateTimeField(blank=True, null=True)
died = models.DateTimeField(blank=True, null=True)
name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255, blank=True, null=True)
first_name = models.CharField(max_length=255, blank=True, null=True)
aliases = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = models.TextField(null=True, blank=True)
def isbn_10_to_13(isbn_10):
''' convert an isbn 10 into an isbn 13 '''
isbn_10 = re.sub(r'[^0-9X]', '', isbn_10)
# drop the last character of the isbn 10 number (the original checkdigit)
converted = isbn_10[:9]
# add "978" to the front
converted = '978' + converted
# add a check digit to the end
# multiply the odd digits by 1 and the even digits by 3 and sum them
try:
checksum = sum(int(i) for i in converted[::2]) + \
sum(int(i) * 3 for i in converted[1::2])
except ValueError:
return None
# add the checksum mod 10 to the end
checkdigit = checksum % 10
if checkdigit != 0:
checkdigit = 10 - checkdigit
return converted + str(checkdigit)
@property
def local_id(self):
''' when a book is ingested from an outside source, it becomes local to
an instance, so it needs a local url for federation. but it still needs
the remote_id for easier deduplication and, if appropriate, to sync with
the remote canonical copy (ditto here for author)'''
return 'https://%s/book/%d' % (DOMAIN, self.id)
@property
def display_name(self):
''' Helper to return a displayable name'''
if self.name:
return self.name
# don't want to return a spurious space if all of these are None
if self.first_name and self.last_name:
return self.first_name + ' ' + self.last_name
return self.last_name or self.first_name
def isbn_13_to_10(isbn_13):
''' convert isbn 13 to 10, if possible '''
if isbn_13[:3] != '978':
return None
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('name', 'display_name'),
ActivityMapping('born', 'born'),
ActivityMapping('died', 'died'),
ActivityMapping('aliases', 'aliases'),
ActivityMapping('bio', 'bio'),
ActivityMapping('openlibrary_key', 'openlibrary_key'),
ActivityMapping('wikipedia_link', 'wikipedia_link'),
]
activity_serializer = activitypub.Author
isbn_13 = re.sub(r'[^0-9X]', '', isbn_13)
# remove '978' and old checkdigit
converted = isbn_13[3:-1]
# calculate checkdigit
# multiple each digit by 10,9,8.. successively and sum them
try:
checksum = sum(int(d) * (10 - idx) for (idx, d) in enumerate(converted))
except ValueError:
return None
checkdigit = checksum % 11
checkdigit = 11 - checkdigit
if checkdigit == 10:
checkdigit = 'X'
return converted + str(checkdigit)

View file

@ -10,25 +10,25 @@ class Connector(BookWyrmModel):
''' book data source connectors '''
identifier = models.CharField(max_length=255, unique=True)
priority = models.IntegerField(default=2)
name = models.CharField(max_length=255, null=True)
name = models.CharField(max_length=255, null=True, blank=True)
local = models.BooleanField(default=False)
connector_file = models.CharField(
max_length=255,
choices=ConnectorFiles.choices
)
api_key = models.CharField(max_length=255, null=True)
api_key = models.CharField(max_length=255, null=True, blank=True)
base_url = models.CharField(max_length=255)
books_url = models.CharField(max_length=255)
covers_url = models.CharField(max_length=255)
search_url = models.CharField(max_length=255, null=True)
search_url = models.CharField(max_length=255, null=True, blank=True)
politeness_delay = models.IntegerField(null=True) #seconds
max_query_count = models.IntegerField(null=True)
politeness_delay = models.IntegerField(null=True, blank=True) #seconds
max_query_count = models.IntegerField(null=True, blank=True)
# how many queries executed in a unit of time, like a day
query_count = models.IntegerField(default=0)
# when to reset the query count back to 0 (ie, after 1 day)
query_count_expiry = models.DateTimeField(auto_now_add=True)
query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True)
class Meta:
''' check that there's code to actually use this connector '''
@ -38,3 +38,9 @@ class Connector(BookWyrmModel):
name='connector_file_valid'
)
]
def __str__(self):
return "{} ({})".format(
self.identifier,
self.id,
)

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

@ -9,6 +9,8 @@ from bookwyrm import books_manager
from bookwyrm.connectors import ConnectorException
from bookwyrm.models import ReadThrough, User, Book
from bookwyrm.utils.fields import JSONField
from .base_model import PrivacyLevels
# Mapping goodreads -> bookwyrm shelf titles.
GOODREADS_SHELVES = {
@ -40,8 +42,14 @@ class ImportJob(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_date = models.DateTimeField(default=timezone.now)
task_id = models.CharField(max_length=100, null=True)
import_status = models.ForeignKey(
'Status', null=True, on_delete=models.PROTECT)
include_reviews = models.BooleanField(default=True)
privacy = models.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
)
retry = models.BooleanField(default=False)
class ImportItem(models.Model):
''' a single line of a csv being imported '''
@ -64,13 +72,14 @@ class ImportItem(models.Model):
def get_book_from_isbn(self):
''' search by isbn '''
search_result = books_manager.first_search_result(self.isbn)
search_result = books_manager.first_search_result(
self.isbn, min_confidence=0.999
)
if search_result:
try:
# don't crash the import when the connector fails
return books_manager.get_or_create_book(search_result.key)
except ConnectorException:
pass
# raises ConnectorException
return books_manager.get_or_create_book(search_result.key)
return None
def get_book_from_title_author(self):
''' search by title and author '''
@ -78,9 +87,24 @@ class ImportItem(models.Model):
self.data['Title'],
self.data['Author']
)
search_result = books_manager.first_search_result(search_term)
search_result = books_manager.first_search_result(
search_term, min_confidence=0.999
)
if search_result:
# raises ConnectorException
return books_manager.get_or_create_book(search_result.key)
return None
@property
def title(self):
''' get the book title '''
return self.data['Title']
@property
def author(self):
''' get the book title '''
return self.data['Author']
@property
def isbn(self):
@ -92,6 +116,7 @@ class ImportItem(models.Model):
''' the goodreads shelf field '''
if self.data['Exclusive Shelf']:
return GOODREADS_SHELVES.get(self.data['Exclusive Shelf'])
return None
@property
def review(self):
@ -107,13 +132,17 @@ class ImportItem(models.Model):
def date_added(self):
''' when the book was added to this dataset '''
if self.data['Date Added']:
return dateutil.parser.parse(self.data['Date Added'])
return timezone.make_aware(
dateutil.parser.parse(self.data['Date Added']))
return None
@property
def date_read(self):
''' the date a book was completed '''
if self.data['Date Read']:
return dateutil.parser.parse(self.data['Date Read'])
return timezone.make_aware(
dateutil.parser.parse(self.data['Date Read']))
return None
@property
def reads(self):
@ -123,6 +152,7 @@ class ImportItem(models.Model):
return [ReadThrough(start_date=self.date_added)]
if self.date_read:
return [ReadThrough(
start_date=self.date_added,
finish_date=self.date_read,
)]
return []

View file

@ -2,23 +2,24 @@
from django.db import models
from bookwyrm import activitypub
from .base_model import BookWyrmModel
from .base_model import ActivitypubMixin, BookWyrmModel
from . import fields
class UserRelationship(BookWyrmModel):
class UserRelationship(ActivitypubMixin, BookWyrmModel):
''' many-to-many through table for followers '''
user_subject = models.ForeignKey(
user_subject = fields.ForeignKey(
'User',
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',
on_delete=models.PROTECT,
related_name='%(class)s_user_object'
related_name='%(class)s_user_object',
activitypub_field='object',
)
# follow or follow_request for pending TODO: blocking?
relationship_id = models.CharField(max_length=100)
class Meta:
''' relationships should be unique '''
@ -34,25 +35,30 @@ class UserRelationship(BookWyrmModel):
)
]
def get_remote_id(self):
activity_serializer = activitypub.Follow
def get_remote_id(self, status=None):
''' use shelf identifier in remote_id '''
status = status or 'follows'
base_path = self.user_subject.remote_id
return '%s#%s/%d' % (base_path, self.status, self.id)
return '%s#%s/%d' % (base_path, status, self.id)
def to_accept_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Accept(
id='%s#accepts/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
def to_reject_activity(self):
''' generate an Accept for this follow request '''
return activitypub.Reject(
id='%s#rejects/follows/' % self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
@ -66,7 +72,7 @@ class UserFollows(UserRelationship):
return cls(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
relationship_id=follow_request.relationship_id,
remote_id=follow_request.remote_id,
)
@ -74,13 +80,16 @@ class UserFollowRequest(UserRelationship):
''' following a user requires manual or automatic confirmation '''
status = 'follow_request'
def to_activity(self):
''' request activity '''
return activitypub.Follow(
id=self.remote_id,
actor=self.user_subject.remote_id,
object=self.user_object.remote_id,
).serialize()
def save(self, *args, **kwargs):
''' make sure the follow relationship doesn't already exist '''
try:
UserFollows.objects.get(
user_subject=self.user_subject,
user_object=self.user_object
)
return None
except UserFollows.DoesNotExist:
return super().save(*args, **kwargs)
class UserBlocks(UserRelationship):

View file

@ -1,16 +1,25 @@
''' puttin' books on shelves '''
import re
from django.db import models
from bookwyrm import activitypub
from .base_model import BookWyrmModel, OrderedCollectionMixin
from .base_model import BookWyrmModel
from .base_model import OrderedCollectionMixin, PrivacyLevels
from . import fields
class Shelf(OrderedCollectionMixin, BookWyrmModel):
''' 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)
user = models.ForeignKey('User', on_delete=models.PROTECT)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='owner')
editable = models.BooleanField(default=True)
privacy = fields.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
)
books = models.ManyToManyField(
'Edition',
symmetrical=False,
@ -18,6 +27,15 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
through_fields=('shelf', 'book')
)
def save(self, *args, **kwargs):
''' set the identifier '''
saved = super().save(*args, **kwargs)
if not self.identifier:
slug = re.sub(r'[^\w]', '', self.name).lower()
self.identifier = '%s-%d' % (slug, self.id)
return super().save(*args, **kwargs)
return saved
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
@ -35,22 +53,27 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
class ShelfBook(BookWyrmModel):
''' many to many join table for books and shelves '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
added_by = models.ForeignKey(
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
shelf = fields.ForeignKey(
'Shelf', on_delete=models.PROTECT, activitypub_field='target')
added_by = fields.ForeignKey(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
on_delete=models.PROTECT,
activitypub_field='actor'
)
activity_serializer = activitypub.AddBook
def to_add_activity(self, user):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
target=self.shelf.remote_id,
).serialize()
def to_remove_activity(self, user):

View file

@ -29,6 +29,9 @@ class SiteSettings(models.Model):
upload_to='static/images/',
default='/static/images/favicon.ico'
)
support_link = models.CharField(max_length=255, null=True, blank=True)
support_title = models.CharField(max_length=100, null=True, blank=True)
admin_email = models.EmailField(max_length=255, null=True, blank=True)
@classmethod
def get(cls):
@ -66,7 +69,7 @@ class SiteInvite(models.Model):
def get_passowrd_reset_expiry():
''' give people a limited time to use the link '''
now = datetime.datetime.now()
now = timezone.now()
return now + datetime.timedelta(days=1)

View file

@ -1,27 +1,34 @@
''' models for storing different kinds of Activities '''
from django.utils import timezone
from django.utils.http import http_date
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import ActivityMapping, BookWyrmModel
from .base_model import BookWyrmModel, PrivacyLevels
from . import fields
from .fields import image_serializer
class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
content = models.TextField(blank=True, null=True)
mention_users = models.ManyToManyField('User', related_name='mention_user')
mention_books = models.ManyToManyField(
'Edition', related_name='mention_book')
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
content = fields.TextField(blank=True, null=True)
mention_users = fields.TagField('User', related_name='mention_user')
mention_books = fields.TagField('Edition', related_name='mention_book')
local = models.BooleanField(default=True)
privacy = models.CharField(max_length=255, default='public')
sensitive = models.BooleanField(default=False)
privacy = models.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
)
sensitive = fields.BooleanField(default=False)
# 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_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
'User',
symmetrical=False,
@ -29,60 +36,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
through_fields=('status', 'user'),
related_name='user_favorites'
)
reply_parent = models.ForeignKey(
reply_parent = fields.ForeignKey(
'self',
null=True,
on_delete=models.PROTECT
on_delete=models.PROTECT,
activitypub_field='inReplyTo',
)
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()
shared_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('url', 'remote_id'),
ActivityMapping('inReplyTo', 'reply_parent'),
ActivityMapping(
'published',
'published_date',
activity_formatter=lambda d: http_date(d.timestamp())
),
ActivityMapping('attributedTo', 'user'),
ActivityMapping('to', 'ap_to'),
ActivityMapping('cc', 'ap_cc'),
ActivityMapping('replies', 'ap_replies'),
]
# 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'),
]
activity_serializer = activitypub.Note
serialize_reverse_fields = [('attachments', 'attachment')]
deserialize_reverse_fields = [('attachments', 'attachment')]
#----- replies collection activitypub ----#
@classmethod
@ -104,60 +68,118 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
**kwargs
)
class GeneratedStatus(Status):
def to_activity(self, pure=False):
''' return tombstone if the status is deleted '''
if self.deleted:
return activitypub.Tombstone(
id=self.remote_id,
url=self.remote_id,
deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat()
).serialize()
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):
''' update user active time '''
if self.user.local:
self.user.last_active_date = timezone.now()
self.user.save()
return super().save(*args, **kwargs)
class GeneratedNote(Status):
''' these are app-generated messages about user activity '''
@property
def ap_pure_content(self):
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
message = self.content
books = ', '.join(
'<a href="%s">"%s"</a>' % (self.book.local_id, self.book.title) \
for book in self.mention_books
'<a href="%s">"%s"</a>' % (book.remote_id, book.title) \
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
pure_activity_serializer = activitypub.Note
pure_type = 'Note'
class Comment(Status):
''' 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
def ap_pure_content(self):
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
(self.book.remote_id, self.book.title)
activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note
pure_type = 'Note'
class Quotation(Status):
''' like a review but without a rating and transient '''
quote = models.TextField()
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
quote = fields.TextField()
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
@property
def ap_pure_content(self):
def pure_content(self):
''' 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.book.local_id,
self.book.remote_id,
self.book.title,
self.content,
)
activity_serializer = activitypub.Quotation
pure_activity_serializer = activitypub.Note
pure_type = 'Note'
class Review(Status):
''' a book review '''
name = models.CharField(max_length=255, null=True)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
rating = models.IntegerField(
name = fields.CharField(max_length=255, null=True)
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
rating = fields.IntegerField(
default=None,
null=True,
blank=True,
@ -165,38 +187,43 @@ class Review(Status):
)
@property
def ap_pure_name(self):
def pure_name(self):
''' clarify review names for mastodon serialization '''
return 'Review of "%s" (%d stars): %s' % (
if self.rating:
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
self.name
)
return 'Review of "%s": %s' % (
self.book.title,
self.rating,
self.name
)
@property
def ap_pure_content(self):
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
(self.book.local_id, self.book.title)
(self.book.remote_id, self.book.title)
activity_serializer = activitypub.Review
pure_activity_serializer = activitypub.Article
pure_type = 'Article'
class Favorite(ActivitypubMixin, BookWyrmModel):
''' fav'ing a post '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
status = models.ForeignKey('Status', on_delete=models.PROTECT)
# ---- activitypub serialization settings for this model ----- #
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'status'),
]
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
status = fields.ForeignKey(
'Status', on_delete=models.PROTECT, activitypub_field='object')
activity_serializer = activitypub.Like
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
class Meta:
''' can't fav things twice '''
@ -205,16 +232,12 @@ class Favorite(ActivitypubMixin, BookWyrmModel):
class Boost(Status):
''' boost'ing a post '''
boosted_status = models.ForeignKey(
boosted_status = fields.ForeignKey(
'Status',
on_delete=models.PROTECT,
related_name="boosters")
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'boosted_status'),
]
related_name='boosters',
activitypub_field='object',
)
activity_serializer = activitypub.Boost
@ -237,10 +260,16 @@ class ReadThrough(BookWyrmModel):
blank=True,
null=True)
def save(self, *args, **kwargs):
''' update user active time '''
self.user.last_active_date = timezone.now()
self.user.save()
super().save(*args, **kwargs)
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''

View file

@ -6,13 +6,12 @@ from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .base_model import OrderedCollectionMixin, BookWyrmModel
from . import fields
class Tag(OrderedCollectionMixin, BookWyrmModel):
''' freeform tags for books '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
name = models.CharField(max_length=100)
name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100)
@classmethod
@ -30,13 +29,33 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
base_path = 'https://%s' % DOMAIN
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):
''' AP for shelving a book'''
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.to_activity(),
target=self.remote_id,
).serialize()
def to_remove_activity(self, user):
@ -48,13 +67,7 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
target=self.to_activity(),
).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:
''' unqiueness constraint '''
unique_together = ('user', 'book', 'name')
unique_together = ('user', 'book', 'tag')

View file

@ -6,43 +6,61 @@ from django.db import models
from django.dispatch import receiver
from bookwyrm import activitypub
from bookwyrm.connectors import get_data
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.signatures import create_key_pair
from bookwyrm.tasks import app
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivityMapping
from .base_model import ActivitypubMixin, BookWyrmModel
from .federated_server import FederatedServer
from . import fields
class User(OrderedCollectionPageMixin, AbstractUser):
''' a user who wants to read books '''
private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True)
inbox = models.CharField(max_length=255, unique=True)
shared_inbox = models.CharField(max_length=255, blank=True, null=True)
username = fields.UsernameField()
key_pair = fields.OneToOneField(
'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(
'FederatedServer',
on_delete=models.PROTECT,
null=True,
blank=True,
)
outbox = models.CharField(max_length=255, unique=True)
summary = models.TextField(blank=True, null=True)
local = models.BooleanField(default=True)
bookwyrm_user = models.BooleanField(default=True)
outbox = fields.RemoteIdField(unique=True)
summary = fields.TextField(default='')
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField(
max_length=255,
null=True,
unique=True
)
# name is your display name, which you can change at will
name = models.CharField(max_length=100, blank=True, null=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
following = models.ManyToManyField(
name = fields.CharField(max_length=100, default='')
avatar = fields.ImageField(
upload_to='avatars/', blank=True, null=True, activitypub_field='icon')
followers = fields.ManyToManyField(
'self',
link_only=True,
symmetrical=False,
through='UserFollows',
through_fields=('user_subject', 'user_object'),
related_name='followers'
through_fields=('user_object', 'user_subject'),
related_name='following'
)
follow_requests = models.ManyToManyField(
'self',
@ -65,93 +83,44 @@ class User(OrderedCollectionPageMixin, AbstractUser):
through_fields=('user', 'status'),
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)
updated_date = models.DateTimeField(auto_now=True)
manually_approves_followers = models.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
last_active_date = models.DateTimeField(auto_now=True)
manually_approves_followers = fields.BooleanField(default=False)
@property
def ap_icon(self):
''' send default icon if one isn't set '''
if self.avatar:
url = self.avatar.url
# TODO not the right way to get the media type
media_type = 'image/%s' % url.split('.')[-1]
else:
url = 'https://%s/static/images/default_avi.jpg' % DOMAIN
media_type = 'image/jpeg'
return activitypub.Image(media_type, url, 'Image')
def display_name(self):
''' show the cleanest version of the user's name possible '''
if self.name != '':
return self.name
return self.localname or self.username
@property
def ap_public_key(self):
''' format the public key block for activitypub '''
return activitypub.PublicKey(**{
'id': '%s/#main-key' % self.remote_id,
'owner': self.remote_id,
'publicKeyPem': self.public_key,
})
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping(
'preferredUsername',
'username',
activity_formatter=lambda x: x.split('@')[0]
),
ActivityMapping('name', 'name'),
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', 'ap_icon'),
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
def to_outbox(self, **kwargs):
''' an ordered collection of statuses '''
queryset = Status.objects.filter(
user=self,
).select_subclasses()
deleted=False,
).select_subclasses().order_by('-published_date')
return self.to_ordered_collection(queryset, \
remote_id=self.outbox, **kwargs)
def to_following_activity(self, **kwargs):
''' activitypub following list '''
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)
def to_followers_activity(self, **kwargs):
''' activitypub followers list '''
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)
def to_activity(self, pure=False):
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()
@ -168,36 +137,73 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return activity_object
@receiver(models.signals.pre_save, sender=User)
def execute_before_save(sender, instance, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to poplate fields
if instance.id:
return
if not instance.local:
# we need to generate a username that uses the domain (webfinger format)
actor_parts = urlparse(instance.remote_id)
instance.username = '%s@%s' % (instance.username, actor_parts.netloc)
return
def save(self, *args, **kwargs):
''' populate fields for new local users '''
# this user already exists, no need to populate fields
if self.id:
return super().save(*args, **kwargs)
# populate fields for local users
instance.remote_id = 'https://%s/user/%s' % (DOMAIN, instance.username)
instance.localname = instance.username
instance.username = '%s@%s' % (instance.username, DOMAIN)
instance.actor = instance.remote_id
instance.inbox = '%s/inbox' % instance.remote_id
instance.shared_inbox = 'https://%s/inbox' % DOMAIN
instance.outbox = '%s/outbox' % instance.remote_id
if not instance.private_key:
instance.private_key, instance.public_key = create_key_pair()
if not self.local:
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = '%s@%s' % (self.username, actor_parts.netloc)
return super().save(*args, **kwargs)
# populate fields for local users
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username)
self.localname = self.username
self.username = '%s@%s' % (self.username, DOMAIN)
self.actor = self.remote_id
self.inbox = '%s/inbox' % self.remote_id
self.shared_inbox = 'https://%s/inbox' % DOMAIN
self.outbox = '%s/outbox' % self.remote_id
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)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
if not instance.local or not created:
if not created:
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 = [{
'name': 'To Read',
'identifier': 'to-read',
@ -216,3 +222,54 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
user=instance,
editable=False
).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)

Some files were not shown because too many files have changed in this diff Show more