rename main code directory

This commit is contained in:
Mouse Reeve
2020-09-17 13:30:54 -07:00
parent b42faad556
commit f77c156733
199 changed files with 0 additions and 0 deletions

0
bookwyrm/__init__.py Normal file
View File

View File

@ -0,0 +1,19 @@
''' bring activitypub functions into the namespace '''
import inspect
import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature
from .note import Note, Article, Comment, Review, Quotation
from .interaction import Boost, Like
from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .person import Person
from .book import Edition, Work, Author
from .verbs import Create, Undo, Update
from .verbs import Follow, Accept, Reject
from .verbs import Add, 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
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_objects = {c[0]: c[1] for c in cls_members \
if hasattr(c[1], 'to_model')}

View File

@ -0,0 +1,118 @@
''' basics for an activitypub serializer '''
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
from django.db.models.fields.related_descriptors \
import ForwardManyToOneDescriptor
class ActivityEncoder(JSONEncoder):
''' used to convert an Activity object into json '''
def default(self, o):
return o.__dict__
@dataclass
class Image:
''' image block '''
mediaType: str
url: str
type: str = 'Image'
@dataclass
class PublicKey:
''' public key block '''
id: str
owner: str
publicKeyPem: str
@dataclass
class Signature:
''' public key block '''
creator: str
created: str
signatureValue: str
type: str = 'RsaSignature2017'
@dataclass(init=False)
class ActivityObject:
''' actor activitypub json '''
id: str
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 '''
for field in fields(self):
try:
value = kwargs[field.name]
except KeyError:
if field.default == MISSING:
raise TypeError('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 '''
if not isinstance(self, model.activity_serializer):
raise TypeError('Wrong activity type for model')
model_fields = [m.name for m in model._meta.get_fields()]
mapped_fields = {}
for mapping in model.activity_mappings:
if mapping.model_key not in model_fields:
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)
mapped_fields[mapping.model_key] = mapping.model_formatter(value)
# updating an existing model isntance
if instance:
for k, v in mapped_fields.items():
setattr(instance, k, v)
instance.save()
return instance
# creating a new model instance
return model.objects.create(**mapped_fields)
def serialize(self):
''' convert to dictionary with context attr '''
data = self.__dict__
data['@context'] = 'https://www.w3.org/ns/activitystreams'
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()
result = result.filter(
remote_id=remote_id
).first()
if not result:
raise ValueError('Could not resolve remote_id in %s model: %s' % \
(model.__name__, remote_id))
return result

View File

@ -0,0 +1,67 @@
''' book and author data '''
from dataclasses import dataclass, field
from typing import List
from .base_activity import ActivityObject, 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]
openlibrary_key: str
librarything_key: str
goodreads_key: str
attachment: List[Image] = field(default=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
type: str = 'Edition'
@dataclass(init=False)
class Work(Book):
''' work instance of a book object '''
lccn: 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
type: str = 'Person'

View File

@ -0,0 +1,20 @@
''' boosting and liking posts '''
from dataclasses import dataclass
from .base_activity import ActivityObject
@dataclass(init=False)
class Like(ActivityObject):
''' a user faving an object '''
actor: str
object: str
type: str = 'Like'
@dataclass(init=False)
class Boost(ActivityObject):
''' boosting a status '''
actor: str
object: str
type: str = 'Announce'

View File

@ -0,0 +1,50 @@
''' note serializer and children thereof '''
from dataclasses import dataclass, field
from typing import Dict, List
from .base_activity import ActivityObject, Image
@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: [])
sensitive: bool = False
type: str = 'Note'
@dataclass(init=False)
class Article(Note):
''' what's an article except a note with more fields '''
name: str
type: str = 'Article'
@dataclass(init=False)
class Comment(Note):
''' like a note but with a book '''
inReplyToBook: str
type: str = 'Comment'
@dataclass(init=False)
class Review(Comment):
''' a full book review '''
name: str
rating: int
type: str = 'Review'
@dataclass(init=False)
class Quotation(Comment):
''' a quote and commentary on a book '''
quote: str
type: str = 'Quotation'

View File

@ -0,0 +1,25 @@
''' defines activitypub collections (lists) '''
from dataclasses import dataclass
from typing import List
from .base_activity import ActivityObject
@dataclass(init=False)
class OrderedCollection(ActivityObject):
''' structure of an ordered collection activity '''
totalItems: int
first: str
last: str = ''
name: str = ''
type: str = 'OrderedCollection'
@dataclass(init=False)
class OrderedCollectionPage(ActivityObject):
''' structure of an ordered collection activity '''
partOf: str
orderedItems: List
next: str
prev: str
type: str = 'OrderedCollectionPage'

View File

@ -0,0 +1,22 @@
''' actor serializer '''
from dataclasses import dataclass, field
from typing import Dict
from .base_activity import ActivityObject, Image, PublicKey
@dataclass(init=False)
class Person(ActivityObject):
''' actor activitypub json '''
preferredUsername: str
name: str
inbox: str
outbox: str
followers: str
summary: str
publicKey: PublicKey
endpoints: Dict
icon: Image = field(default=lambda: {})
fedireadsUser: str = False
manuallyApprovesFollowers: str = False
discoverable: str = True
type: str = 'Person'

View File

@ -0,0 +1,68 @@
''' undo wrapper activity '''
from dataclasses import dataclass
from typing import List
from .base_activity import ActivityObject, Signature
@dataclass(init=False)
class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of
abstraction but w/e '''
actor: str
object: ActivityObject
@dataclass(init=False)
class Create(Verb):
''' Create activity '''
to: List
cc: List
signature: Signature
type: str = 'Create'
@dataclass(init=False)
class Update(Verb):
''' Update activity '''
to: List
type: str = 'Update'
@dataclass(init=False)
class Undo(Verb):
''' Undo an activity '''
type: str = 'Undo'
@dataclass(init=False)
class Follow(Verb):
''' Follow activity '''
type: str = 'Follow'
@dataclass(init=False)
class Accept(Verb):
''' Accept activity '''
object: Follow
type: str = 'Accept'
@dataclass(init=False)
class Reject(Verb):
''' Reject activity '''
object: Follow
type: str = 'Reject'
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
target: ActivityObject
type: str = 'Add'
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''
target: ActivityObject
type: str = 'Remove'

120
bookwyrm/books_manager.py Normal file
View File

@ -0,0 +1,120 @@
''' select and call a connector for whatever book task needs doing '''
import importlib
from urllib.parse import urlparse
from requests import HTTPError
from fedireads import models
from fedireads.tasks import app
def get_edition(book_id):
''' look up a book in the db and return an edition '''
book = models.Book.objects.select_subclasses().get(id=book_id)
if isinstance(book, models.Work):
book = book.default_edition
return book
def get_or_create_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)
book = connector.get_or_create_book(remote_id)
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)
identifier = url.netloc
if not identifier:
raise ValueError('Invalid remote id')
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file='fedireads_connector',
base_url='https://%s' % identifier,
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
priority=3
)
return load_connector(connector_info)
@app.task
def load_more_data(book_id):
''' background the work of getting all 10,000 editions of LoTR '''
book = models.Book.objects.select_subclasses().get(id=book_id)
connector = load_connector(book.connector)
connector.expand_book_data(book)
def search(query):
''' 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)
except HTTPError:
continue
result_set = [r for r in result_set \
if dedup_slug(r) not in result_index]
# `|=` concats two sets. WE ARE GETTING FANCY HERE
result_index |= set(dedup_slug(r) for r in result_set)
results.append({
'connector': connector,
'results': result_set,
})
return results
def local_search(query):
''' only look at local search results '''
connector = load_connector(models.Connector.objects.get(local=True))
return connector.search(query)
def first_search_result(query):
''' search until you find a result that fits '''
for connector in get_connectors():
result = connector.search(query)
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():
yield load_connector(info)
def load_connector(connector_info):
''' instantiate the connector class '''
connector = importlib.import_module(
'fedireads.connectors.%s' % connector_info.connector_file
)
return connector.Connector(connector_info.identifier)

90
bookwyrm/broadcast.py Normal file
View File

@ -0,0 +1,90 @@
''' send out activitypub messages '''
import json
from django.utils.http import http_date
import requests
from fedireads import models
from fedireads.activitypub import ActivityEncoder
from fedireads.tasks import app
from fedireads.signatures import make_signature, make_digest
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(fedireads_user=(software == 'fedireads'))
# we want shared inboxes when available
shared = followers.filter(
shared_inbox__isnull=False
).values_list('shared_inbox', flat=True).distinct()
# if a user doesn't have a shared inbox, we need their personal inbox
# iirc pixelfed doesn't have shared inboxes
inboxes = followers.filter(
shared_inbox__isnull=True
).values_list('inbox', flat=True)
return list(shared) + list(inboxes)
def broadcast(sender, activity, software=None, \
privacy='public', direct_recipients=None):
''' send out an event '''
# 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(
sender.id,
json.dumps(activity, cls=ActivityEncoder),
recipients
)
@app.task
def broadcast_task(sender_id, activity, recipients):
''' the celery task for broadcast '''
sender = models.User.objects.get(id=sender_id)
errors = []
for recipient in 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': e,
'recipient': recipient,
'activity': activity,
})
return errors
def sign_and_send(sender, activity, destination):
''' crpyto whatever and http junk '''
now = http_date()
if not sender.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(
destination,
data=data,
headers={
'Date': now,
'Digest': digest,
'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8',
},
)
if not response.ok:
response.raise_for_status()
return response

View File

@ -0,0 +1,2 @@
''' bring connectors into the namespace '''
from .settings import CONNECTORS

View File

@ -0,0 +1,311 @@
''' functionality outline for a book data connector '''
from abc import ABC, abstractmethod
from dateutil import parser
import pytz
import requests
from django.db import transaction
from fedireads import models
class AbstractConnector(ABC):
''' generic book data connector '''
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',
'books_url',
'covers_url',
'search_url',
'max_query_count',
'name',
'identifier',
'local'
]
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):
''' free text search '''
resp = requests.get(
'%s%s' % (self.search_url, query),
headers={
'Accept': 'application/json; charset=utf-8',
},
)
if not resp.ok:
resp.raise_for_status()
data = resp.json()
results = []
for doc in self.parse_search_data(data)[:10]:
results.append(self.format_search_result(doc))
return results
def get_or_create_book(self, remote_id):
''' pull up a book record by whatever means possible '''
# try to load the book
book = models.Book.objects.select_subclasses().filter(
remote_id=remote_id
).first()
if book:
if isinstance(book, models.Work):
return book.default_edition
return book
# no book was found, so we start creating a new one
data = get_data(remote_id)
work = None
edition = None
if self.is_work_data(data):
work_data = data
# if we requested a work and there's already an edition, we're set
work = self.match_from_mappings(work_data, models.Work)
if work and work.default_edition:
return work.default_edition
# no such luck, we need more information.
try:
edition_data = self.get_edition_from_work_data(work_data)
except KeyError:
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
else:
edition_data = data
edition = self.match_from_mappings(edition_data, models.Edition)
# no need to figure out about the work if we already know about it
if edition and edition.parent_work:
return edition
# no such luck, we need more information.
try:
work_data = self.get_work_from_edition_date(edition_data)
except KeyError:
# remember this hack: re-use the work data as the edition data
work_data = data
# 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 = self.create_book(work_key, work_data, models.Work)
if not edition:
ed_key = edition_data.get('url')
edition = self.create_book(ed_key, edition_data, models.Edition)
edition.default = True
edition.parent_work = work
edition.save()
# now's our change to fill in author gaps
if not edition.authors and work.authors:
edition.authors.set(work.authors.all())
edition.author_text = work.author_text
edition.save()
return edition
def create_book(self, remote_id, data, model):
''' create a work or edition from data '''
book = model.objects.create(
remote_id=remote_id,
title=data['title'],
connector=self.connector,
)
return self.update_book_from_data(book, data)
def update_book_from_data(self, book, data, update_cover=True):
''' for creating a new book or syncing with data '''
book = update_from_mappings(book, data, self.book_mappings)
for author in self.get_authors_from_data(data):
book.authors.add(author)
book.author_text = ', '.join(a.name for a in book.authors.all())
book.save()
if not update_cover:
return book
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def update_book(self, book, data=None):
''' load new data '''
if not book.sync and not book.sync_cover:
return
if not data:
key = getattr(book, self.key_name)
data = self.load_book_data(key)
if book.sync:
book = self.update_book_from_data(
book, data, update_cover=book.sync_cover)
else:
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def match_from_mappings(self, data, model):
''' try to find existing copies of this book using various keys '''
relevent_mappings = [m for m in self.key_mappings if \
not m.model or model == m.model]
for mapping in relevent_mappings:
# check if this field is present in the data
value = data.get(mapping.remote_field)
if not value:
continue
# extract the value in the right format
value = mapping.formatter(value)
# search our database for a matching book
kwargs = {mapping.local_field: value}
match = model.objects.filter(**kwargs).first()
if match:
return match
return None
@abstractmethod
def is_work_data(self, data):
''' differentiate works and editions '''
@abstractmethod
def get_edition_from_work_data(self, data):
''' every work needs at least one edition '''
@abstractmethod
def get_work_from_edition_date(self, data):
''' every edition needs a work '''
@abstractmethod
def get_authors_from_data(self, data):
''' load author data '''
@abstractmethod
def get_cover_from_data(self, data):
''' load cover '''
@abstractmethod
def 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 '''
def update_from_mappings(obj, data, mappings):
''' assign data to model with mappings '''
for mapping in mappings:
# check if this field is present in the data
value = data.get(mapping.remote_field)
if not value:
continue
# extract the value in the right format
value = mapping.formatter(value)
# assign the formatted value to the model
obj.__setattr__(mapping.local_field, value)
return obj
def get_date(date_string):
''' helper function to try to interpret dates '''
if not date_string:
return None
try:
return pytz.utc.localize(parser.parse(date_string))
except ValueError:
pass
try:
return parser.parse(date_string)
except ValueError:
return None
def get_data(url):
''' wrapper for request.get '''
resp = requests.get(
url,
headers={
'Accept': 'application/json; charset=utf-8',
},
)
if not resp.ok:
resp.raise_for_status()
data = resp.json()
return data
class SearchResult(object):
''' standardized search result object '''
def __init__(self, title, key, author, year):
self.title = title
self.key = key
self.author = author
self.year = year
def __repr__(self):
return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author)
class Mapping(object):
''' associate a local database field with a field in an external dataset '''
def __init__(
self, local_field, remote_field=None, formatter=None, model=None):
noop = lambda x: x
self.local_field = local_field
self.remote_field = remote_field or local_field
self.formatter = formatter or noop
self.model = model

View File

@ -0,0 +1,108 @@
''' using another fedireads 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 fedireads import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import update_from_mappings, get_date, get_data
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
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

@ -0,0 +1,222 @@
''' openlibrary data connector '''
import re
import requests
from django.core.files.base import ContentFile
from fedireads import models
from .abstract_connector import AbstractConnector, SearchResult, Mapping
from .abstract_connector import update_from_mappings
from .abstract_connector import get_date, get_data
from .openlibrary_languages import languages
class Connector(AbstractConnector):
''' instantiate a connector for OL '''
def __init__(self, identifier):
super().__init__(identifier)
get_first = lambda a: a[0]
self.key_mappings = [
Mapping('isbn_13', model=models.Edition, formatter=get_first),
Mapping('isbn_10', model=models.Edition, formatter=get_first),
Mapping('lccn', model=models.Work, formatter=get_first),
Mapping(
'oclc_number',
remote_field='oclc_numbers',
model=models.Edition,
formatter=get_first
),
Mapping(
'openlibrary_key',
remote_field='key',
formatter=get_openlibrary_key
),
Mapping('goodreads_key'),
Mapping('asin'),
]
self.book_mappings = self.key_mappings + [
Mapping('sort_title'),
Mapping('subtitle'),
Mapping('description', formatter=get_description),
Mapping('languages', formatter=get_languages),
Mapping('series', formatter=get_first),
Mapping('series_number'),
Mapping('subjects'),
Mapping('subject_places'),
Mapping(
'first_published_date',
remote_field='first_publish_date',
formatter=get_date
),
Mapping(
'published_date',
remote_field='publish_date',
formatter=get_date
),
Mapping(
'pages',
model=models.Edition,
remote_field='number_of_pages'
),
Mapping('physical_format', model=models.Edition),
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', formatter=get_description),
]
def is_work_data(self, data):
return bool(re.match(r'^[\/\w]+OL\d+W$', data['key']))
def get_edition_from_work_data(self, data):
try:
key = data['key']
except KeyError:
return False
url = '%s/%s/editions' % (self.books_url, key)
data = get_data(url)
return pick_default_edition(data['entries'])
def get_work_from_edition_date(self, data):
try:
key = data['works'][0]['key']
except (IndexError, KeyError):
return False
url = '%s/%s' % (self.books_url, key)
return get_data(url)
def get_authors_from_data(self, data):
''' parse author json and load or create authors '''
for author_blob in data.get('authors', []):
author_blob = author_blob.get('author', author_blob)
# this id is "/authors/OL1234567A" and we want just "OL1234567A"
author_id = author_blob['key'].split('/')[-1]
yield self.get_or_create_author(author_id)
def get_cover_from_data(self, data):
''' ask openlibrary for the cover '''
if not data.get('covers'):
return None
cover_id = data.get('covers')[0]
image_name = '%s-M.jpg' % cover_id
url = '%s/b/id/%s' % (self.covers_url, image_name)
response = requests.get(url)
if not response.ok:
response.raise_for_status()
image_content = ContentFile(response.content)
return [image_name, image_content]
def parse_search_data(self, data):
return data.get('docs')
def format_search_result(self, doc):
# build the remote id from the openlibrary key
key = self.books_url + doc['key']
author = doc.get('author_name') or ['Unknown']
return SearchResult(
doc.get('title'),
key,
', '.join(author),
doc.get('first_publish_year'),
)
def load_edition_data(self, olkey):
''' query openlibrary for editions of a work '''
url = '%s/works/%s/editions.json' % (self.books_url, olkey)
return get_data(url)
def expand_book_data(self, book):
work = book
if isinstance(book, models.Edition):
work = book.parent_work
edition_options = self.load_edition_data(work.openlibrary_key)
for edition_data in edition_options.get('entries'):
olkey = edition_data.get('key').split('/')[-1]
if models.Edition.objects.filter(openlibrary_key=olkey).count():
continue
edition = self.create_book(olkey, edition_data, models.Edition)
edition.parent_work = work
edition.save()
if not edition.authors and work.authors:
edition.authors.set(work.authors.all())
def get_or_create_author(self, olkey):
''' load that author '''
if not re.match(r'^OL\d+A$', olkey):
raise ValueError('Invalid OpenLibrary author ID')
try:
return models.Author.objects.get(openlibrary_key=olkey)
except models.Author.DoesNotExist:
pass
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
def get_description(description_blob):
''' descriptions can be a string or a dict '''
if isinstance(description_blob, dict):
return description_blob.get('value')
return description_blob
def get_openlibrary_key(key):
''' convert /books/OL27320736M into OL27320736M '''
return key.split('/')[-1]
def get_languages(language_blob):
''' /language/eng -> English '''
langs = []
for lang in language_blob:
langs.append(
languages.get(lang.get('key', ''), None)
)
return langs
def pick_default_edition(options):
''' favor physical copies with covers in english '''
if not options:
return None
if len(options) == 1:
return options[0]
options = [e for e in options if e.get('cover')] or options
options = [e for e in options if \
'/languages/eng' in str(e.get('languages'))] or options
formats = ['paperback', 'hardcover', 'mass market paperback']
options = [e for e in options if \
str(e.get('physical_format')).lower() in formats] or options
options = [e for e in options if e.get('isbn_13')] or options
options = [e for e in options if e.get('ocaid')] or options
return options[0]

View File

@ -0,0 +1,467 @@
''' key lookups for openlibrary languages '''
languages = {
'/languages/eng': 'English',
'/languages/fre': 'French',
'/languages/spa': 'Spanish',
'/languages/ger': 'German',
'/languages/rus': 'Russian',
'/languages/ita': 'Italian',
'/languages/chi': 'Chinese',
'/languages/jpn': 'Japanese',
'/languages/por': 'Portuguese',
'/languages/ara': 'Arabic',
'/languages/pol': 'Polish',
'/languages/heb': 'Hebrew',
'/languages/kor': 'Korean',
'/languages/dut': 'Dutch',
'/languages/ind': 'Indonesian',
'/languages/lat': 'Latin',
'/languages/und': 'Undetermined',
'/languages/cmn': 'Mandarin',
'/languages/hin': 'Hindi',
'/languages/swe': 'Swedish',
'/languages/dan': 'Danish',
'/languages/urd': 'Urdu',
'/languages/hun': 'Hungarian',
'/languages/cze': 'Czech',
'/languages/tur': 'Turkish',
'/languages/ukr': 'Ukrainian',
'/languages/gre': 'Greek',
'/languages/vie': 'Vietnamese',
'/languages/bul': 'Bulgarian',
'/languages/ben': 'Bengali',
'/languages/rum': 'Romanian',
'/languages/cat': 'Catalan',
'/languages/nor': 'Norwegian',
'/languages/tha': 'Thai',
'/languages/per': 'Persian',
'/languages/scr': 'Croatian',
'/languages/mul': 'Multiple languages',
'/languages/fin': 'Finnish',
'/languages/tam': 'Tamil',
'/languages/guj': 'Gujarati',
'/languages/mar': 'Marathi',
'/languages/scc': 'Serbian',
'/languages/pan': 'Panjabi',
'/languages/wel': 'Welsh',
'/languages/tel': 'Telugu',
'/languages/yid': 'Yiddish',
'/languages/kan': 'Kannada',
'/languages/slo': 'Slovak',
'/languages/san': 'Sanskrit',
'/languages/arm': 'Armenian',
'/languages/mal': 'Malayalam',
'/languages/may': 'Malay',
'/languages/bur': 'Burmese',
'/languages/slv': 'Slovenian',
'/languages/lit': 'Lithuanian',
'/languages/tib': 'Tibetan',
'/languages/lav': 'Latvian',
'/languages/est': 'Estonian',
'/languages/nep': 'Nepali',
'/languages/ori': 'Oriya',
'/languages/mon': 'Mongolian',
'/languages/alb': 'Albanian',
'/languages/iri': 'Irish',
'/languages/geo': 'Georgian',
'/languages/afr': 'Afrikaans',
'/languages/grc': 'Ancient Greek',
'/languages/mac': 'Macedonian',
'/languages/bel': 'Belarusian',
'/languages/ice': 'Icelandic',
'/languages/srp': 'Serbian',
'/languages/snh': 'Sinhalese',
'/languages/snd': 'Sindhi',
'/languages/ota': 'Turkish, Ottoman',
'/languages/kur': 'Kurdish',
'/languages/aze': 'Azerbaijani',
'/languages/pus': 'Pushto',
'/languages/amh': 'Amharic',
'/languages/gag': 'Galician',
'/languages/hrv': 'Croatian',
'/languages/sin': 'Sinhalese',
'/languages/asm': 'Assamese',
'/languages/uzb': 'Uzbek',
'/languages/gae': 'Scottish Gaelix',
'/languages/kaz': 'Kazakh',
'/languages/swa': 'Swahili',
'/languages/bos': 'Bosnian',
'/languages/glg': 'Galician ',
'/languages/baq': 'Basque',
'/languages/tgl': 'Tagalog',
'/languages/raj': 'Rajasthani',
'/languages/gle': 'Irish',
'/languages/lao': 'Lao',
'/languages/jav': 'Javanese',
'/languages/mai': 'Maithili',
'/languages/tgk': 'Tajik ',
'/languages/khm': 'Khmer',
'/languages/roh': 'Raeto-Romance',
'/languages/kok': 'Konkani ',
'/languages/sit': 'Sino-Tibetan (Other)',
'/languages/mol': 'Moldavian',
'/languages/kir': 'Kyrgyz',
'/languages/new': 'Newari',
'/languages/inc': 'Indic (Other)',
'/languages/frm': 'French, Middle (ca. 1300-1600)',
'/languages/esp': 'Esperanto',
'/languages/hau': 'Hausa',
'/languages/tag': 'Tagalog',
'/languages/tuk': 'Turkmen',
'/languages/enm': 'English, Middle (1100-1500)',
'/languages/map': 'Austronesian (Other)',
'/languages/pli': 'Pali',
'/languages/fro': 'French, Old (ca. 842-1300)',
'/languages/nic': 'Niger-Kordofanian (Other)',
'/languages/tir': 'Tigrinya',
'/languages/wen': 'Sorbian (Other)',
'/languages/bho': 'Bhojpuri',
'/languages/roa': 'Romance (Other)',
'/languages/tut': 'Altaic (Other)',
'/languages/bra': 'Braj',
'/languages/sun': 'Sundanese',
'/languages/fiu': 'Finno-Ugrian (Other)',
'/languages/far': 'Faroese',
'/languages/ban': 'Balinese',
'/languages/tar': 'Tatar',
'/languages/bak': 'Bashkir',
'/languages/tat': 'Tatar',
'/languages/chu': 'Church Slavic',
'/languages/dra': 'Dravidian (Other)',
'/languages/pra': 'Prakrit languages',
'/languages/paa': 'Papuan (Other)',
'/languages/doi': 'Dogri',
'/languages/lah': 'Lahndā',
'/languages/mni': 'Manipuri',
'/languages/yor': 'Yoruba',
'/languages/gmh': 'German, Middle High (ca. 1050-1500)',
'/languages/kas': 'Kashmiri',
'/languages/fri': 'Frisian',
'/languages/mla': 'Malagasy',
'/languages/egy': 'Egyptian',
'/languages/rom': 'Romani',
'/languages/syr': 'Syriac, Modern',
'/languages/cau': 'Caucasian (Other)',
'/languages/hbs': 'Serbo-Croatian',
'/languages/sai': 'South American Indian (Other)',
'/languages/pro': 'Provençal (to 1500)',
'/languages/cpf': 'Creoles and Pidgins, French-based (Other)',
'/languages/ang': 'English, Old (ca. 450-1100)',
'/languages/bal': 'Baluchi',
'/languages/gla': 'Scottish Gaelic',
'/languages/chv': 'Chuvash',
'/languages/kin': 'Kinyarwanda',
'/languages/zul': 'Zulu',
'/languages/sla': 'Slavic (Other)',
'/languages/som': 'Somali',
'/languages/mlt': 'Maltese',
'/languages/uig': 'Uighur',
'/languages/mlg': 'Malagasy',
'/languages/sho': 'Shona',
'/languages/lan': 'Occitan (post 1500)',
'/languages/bre': 'Breton',
'/languages/sco': 'Scots',
'/languages/sso': 'Sotho',
'/languages/myn': 'Mayan languages',
'/languages/xho': 'Xhosa',
'/languages/gem': 'Germanic (Other)',
'/languages/esk': 'Eskimo languages',
'/languages/akk': 'Akkadian',
'/languages/div': 'Maldivian',
'/languages/sah': 'Yakut',
'/languages/tsw': 'Tswana',
'/languages/nso': 'Northern Sotho',
'/languages/pap': 'Papiamento',
'/languages/bnt': 'Bantu (Other)',
'/languages/oss': 'Ossetic',
'/languages/cre': 'Cree',
'/languages/ibo': 'Igbo',
'/languages/fao': 'Faroese',
'/languages/nai': 'North American Indian (Other)',
'/languages/mag': 'Magahi',
'/languages/arc': 'Aramaic',
'/languages/epo': 'Esperanto',
'/languages/kha': 'Khasi',
'/languages/oji': 'Ojibwa',
'/languages/que': 'Quechua',
'/languages/lug': 'Ganda',
'/languages/mwr': 'Marwari',
'/languages/awa': 'Awadhi ',
'/languages/cor': 'Cornish',
'/languages/lad': 'Ladino',
'/languages/dzo': 'Dzongkha',
'/languages/cop': 'Coptic',
'/languages/nah': 'Nahuatl',
'/languages/cai': 'Central American Indian (Other)',
'/languages/phi': 'Philippine (Other)',
'/languages/moh': 'Mohawk',
'/languages/crp': 'Creoles and Pidgins (Other)',
'/languages/nya': 'Nyanja',
'/languages/wol': 'Wolof ',
'/languages/haw': 'Hawaiian',
'/languages/eth': 'Ethiopic',
'/languages/mis': 'Miscellaneous languages',
'/languages/mkh': 'Mon-Khmer (Other)',
'/languages/alg': 'Algonquian (Other)',
'/languages/nde': 'Ndebele (Zimbabwe)',
'/languages/ssa': 'Nilo-Saharan (Other)',
'/languages/chm': 'Mari',
'/languages/che': 'Chechen',
'/languages/gez': 'Ethiopic',
'/languages/ven': 'Venda',
'/languages/cam': 'Khmer',
'/languages/fur': 'Friulian',
'/languages/ful': 'Fula',
'/languages/gal': 'Oromo',
'/languages/jrb': 'Judeo-Arabic',
'/languages/bua': 'Buriat',
'/languages/ady': 'Adygei',
'/languages/bem': 'Bemba',
'/languages/kar': 'Karen languages',
'/languages/sna': 'Shona',
'/languages/twi': 'Twi',
'/languages/btk': 'Batak',
'/languages/kaa': 'Kara-Kalpak',
'/languages/kom': 'Komi',
'/languages/sot': 'Sotho',
'/languages/tso': 'Tsonga',
'/languages/cpe': 'Creoles and Pidgins, English-based (Other)',
'/languages/gua': 'Guarani',
'/languages/mao': 'Maori',
'/languages/mic': 'Micmac',
'/languages/swz': 'Swazi',
'/languages/taj': 'Tajik',
'/languages/smo': 'Samoan',
'/languages/ace': 'Achinese',
'/languages/afa': 'Afroasiatic (Other)',
'/languages/lap': 'Sami',
'/languages/min': 'Minangkabau',
'/languages/oci': 'Occitan (post 1500)',
'/languages/tsn': 'Tswana',
'/languages/pal': 'Pahlavi',
'/languages/sux': 'Sumerian',
'/languages/ewe': 'Ewe',
'/languages/him': 'Himachali',
'/languages/kaw': 'Kawi',
'/languages/lus': 'Lushai',
'/languages/ceb': 'Cebuano',
'/languages/chr': 'Cherokee',
'/languages/fil': 'Filipino',
'/languages/ndo': 'Ndonga',
'/languages/ilo': 'Iloko',
'/languages/kbd': 'Kabardian',
'/languages/orm': 'Oromo',
'/languages/dum': 'Dutch, Middle (ca. 1050-1350)',
'/languages/bam': 'Bambara',
'/languages/goh': 'Old High German',
'/languages/got': 'Gothic',
'/languages/kon': 'Kongo',
'/languages/mun': 'Munda (Other)',
'/languages/kru': 'Kurukh',
'/languages/pam': 'Pampanga',
'/languages/grn': 'Guarani',
'/languages/gaa': '',
'/languages/fry': 'Frisian',
'/languages/iba': 'Iban',
'/languages/mak': 'Makasar',
'/languages/kik': 'Kikuyu',
'/languages/cho': 'Choctaw',
'/languages/cpp': 'Creoles and Pidgins, Portuguese-based (Other)',
'/languages/dak': 'Dakota',
'/languages/udm': 'Udmurt ',
'/languages/hat': 'Haitian French Creole',
'/languages/mus': 'Creek',
'/languages/ber': 'Berber (Other)',
'/languages/hil': 'Hiligaynon',
'/languages/iro': 'Iroquoian (Other)',
'/languages/kua': 'Kuanyama',
'/languages/mno': 'Manobo languages',
'/languages/run': 'Rundi',
'/languages/sat': 'Santali',
'/languages/shn': 'Shan',
'/languages/tyv': 'Tuvinian',
'/languages/chg': 'Chagatai',
'/languages/syc': 'Syriac',
'/languages/ath': 'Athapascan (Other)',
'/languages/aym': 'Aymara',
'/languages/bug': 'Bugis',
'/languages/cel': 'Celtic (Other)',
'/languages/int': 'Interlingua (International Auxiliary Language Association)',
'/languages/xal': 'Oirat',
'/languages/ava': 'Avaric',
'/languages/son': 'Songhai',
'/languages/tah': 'Tahitian',
'/languages/tet': 'Tetum',
'/languages/ira': 'Iranian (Other)',
'/languages/kac': 'Kachin',
'/languages/nob': 'Norwegian (Bokmål)',
'/languages/vai': 'Vai',
'/languages/bik': 'Bikol',
'/languages/mos': 'Mooré',
'/languages/tig': 'Tigré',
'/languages/fat': 'Fanti',
'/languages/her': 'Herero',
'/languages/kal': 'Kalâtdlisut',
'/languages/mad': 'Madurese',
'/languages/yue': 'Cantonese',
'/languages/chn': 'Chinook jargon',
'/languages/hmn': 'Hmong',
'/languages/lin': 'Lingala',
'/languages/man': 'Mandingo',
'/languages/nds': 'Low German',
'/languages/bas': 'Basa',
'/languages/gay': 'Gayo',
'/languages/gsw': 'gsw',
'/languages/ine': 'Indo-European (Other)',
'/languages/kro': 'Kru (Other)',
'/languages/kum': 'Kumyk',
'/languages/tsi': 'Tsimshian',
'/languages/zap': 'Zapotec',
'/languages/ach': 'Acoli',
'/languages/ada': 'Adangme',
'/languages/aka': 'Akan',
'/languages/khi': 'Khoisan (Other)',
'/languages/srd': 'Sardinian',
'/languages/arn': 'Mapuche',
'/languages/dyu': 'Dyula',
'/languages/loz': 'Lozi',
'/languages/ltz': 'Luxembourgish',
'/languages/sag': 'Sango (Ubangi Creole)',
'/languages/lez': 'Lezgian',
'/languages/luo': 'Luo (Kenya and Tanzania)',
'/languages/ssw': 'Swazi ',
'/languages/krc': 'Karachay-Balkar',
'/languages/nyn': 'Nyankole',
'/languages/sal': 'Salishan languages',
'/languages/jpr': 'Judeo-Persian',
'/languages/pau': 'Palauan',
'/languages/smi': 'Sami',
'/languages/aar': 'Afar',
'/languages/abk': 'Abkhaz',
'/languages/gon': 'Gondi',
'/languages/nzi': 'Nzima',
'/languages/sam': 'Samaritan Aramaic',
'/languages/sao': 'Samoan',
'/languages/srr': 'Serer',
'/languages/apa': 'Apache languages',
'/languages/crh': 'Crimean Tatar',
'/languages/efi': 'Efik',
'/languages/iku': 'Inuktitut',
'/languages/nav': 'Navajo',
'/languages/pon': 'Ponape',
'/languages/tmh': 'Tamashek',
'/languages/aus': 'Australian languages',
'/languages/oto': 'Otomian languages',
'/languages/war': 'Waray',
'/languages/ypk': 'Yupik languages',
'/languages/ave': 'Avestan',
'/languages/cus': 'Cushitic (Other)',
'/languages/del': 'Delaware',
'/languages/fon': 'Fon',
'/languages/ina': 'Interlingua (International Auxiliary Language Association)',
'/languages/myv': 'Erzya',
'/languages/pag': 'Pangasinan',
'/languages/peo': 'Old Persian (ca. 600-400 B.C.)',
'/languages/vls': 'Flemish',
'/languages/bai': 'Bamileke languages',
'/languages/bla': 'Siksika',
'/languages/day': 'Dayak',
'/languages/men': 'Mende',
'/languages/tai': 'Tai',
'/languages/ton': 'Tongan',
'/languages/uga': 'Ugaritic',
'/languages/yao': 'Yao (Africa)',
'/languages/zza': 'Zaza',
'/languages/bin': 'Edo',
'/languages/frs': 'East Frisian',
'/languages/inh': 'Ingush',
'/languages/mah': 'Marshallese',
'/languages/sem': 'Semitic (Other)',
'/languages/art': 'Artificial (Other)',
'/languages/chy': 'Cheyenne',
'/languages/cmc': 'Chamic languages',
'/languages/dar': 'Dargwa',
'/languages/dua': 'Duala',
'/languages/elx': 'Elamite',
'/languages/fan': 'Fang',
'/languages/fij': 'Fijian',
'/languages/gil': 'Gilbertese',
'/languages/ijo': 'Ijo',
'/languages/kam': 'Kamba',
'/languages/nog': 'Nogai',
'/languages/non': 'Old Norse',
'/languages/tem': 'Temne',
'/languages/arg': 'Aragonese',
'/languages/arp': 'Arapaho',
'/languages/arw': 'Arawak',
'/languages/din': 'Dinka',
'/languages/grb': 'Grebo',
'/languages/kos': 'Kusaie',
'/languages/lub': 'Luba-Katanga',
'/languages/mnc': 'Manchu',
'/languages/nyo': 'Nyoro',
'/languages/rar': 'Rarotongan',
'/languages/sel': 'Selkup',
'/languages/tkl': 'Tokelauan',
'/languages/tog': 'Tonga (Nyasa)',
'/languages/tum': 'Tumbuka',
'/languages/alt': 'Altai',
'/languages/ase': 'American Sign Language',
'/languages/ast': 'Asturian',
'/languages/chk': 'Chuukese',
'/languages/cos': 'Corsican',
'/languages/ewo': 'Ewondo',
'/languages/gor': 'Gorontalo',
'/languages/hmo': 'Hiri Motu',
'/languages/lol': 'Mongo-Nkundu',
'/languages/lun': 'Lunda',
'/languages/mas': 'Masai',
'/languages/niu': 'Niuean',
'/languages/rup': 'Aromanian',
'/languages/sas': 'Sasak',
'/languages/sio': 'Siouan (Other)',
'/languages/sus': 'Susu',
'/languages/zun': 'Zuni',
'/languages/bat': 'Baltic (Other)',
'/languages/car': 'Carib',
'/languages/cha': 'Chamorro',
'/languages/kab': 'Kabyle',
'/languages/kau': 'Kanuri',
'/languages/kho': 'Khotanese',
'/languages/lua': 'Luba-Lulua',
'/languages/mdf': 'Moksha',
'/languages/nbl': 'Ndebele (South Africa)',
'/languages/umb': 'Umbundu',
'/languages/wak': 'Wakashan languages',
'/languages/wal': 'Wolayta',
'/languages/ale': 'Aleut',
'/languages/bis': 'Bislama',
'/languages/gba': 'Gbaya',
'/languages/glv': 'Manx',
'/languages/gul': 'Gullah',
'/languages/ipk': 'Inupiaq',
'/languages/krl': 'Karelian',
'/languages/lam': 'Lamba (Zambia and Congo)',
'/languages/sad': 'Sandawe',
'/languages/sid': 'Sidamo',
'/languages/snk': 'Soninke',
'/languages/srn': 'Sranan',
'/languages/suk': 'Sukuma',
'/languages/ter': 'Terena',
'/languages/tiv': 'Tiv',
'/languages/tli': 'Tlingit',
'/languages/tpi': 'Tok Pisin',
'/languages/tvl': 'Tuvaluan',
'/languages/yap': 'Yapese',
'/languages/eka': 'Ekajuk',
'/languages/hsb': 'Upper Sorbian',
'/languages/ido': 'Ido',
'/languages/kmb': 'Kimbundu',
'/languages/kpe': 'Kpelle',
'/languages/mwl': 'Mirandese',
'/languages/nno': 'Nynorsk',
'/languages/nub': 'Nubian languages',
'/languages/osa': 'Osage',
'/languages/sme': 'Northern Sami',
'/languages/znd': 'Zande languages',
}

View File

@ -0,0 +1,81 @@
''' using a fedireads instance as a source of book data '''
from django.contrib.postgres.search import SearchRank, SearchVector
from fedireads import models
from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector):
''' instantiate a connector '''
def __init__(self, identifier):
super().__init__(identifier)
def search(self, query):
''' right now you can't search fedireads 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('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')
results = models.Edition.objects.annotate(
search=vector
).annotate(
rank=SearchRank(vector, query)
).filter(
rank__gt=0
).order_by('-rank')
results = results.filter(default=True) or results
search_results = []
for book in results[:10]:
search_results.append(
self.format_search_result(book)
)
return search_results
def format_search_result(self, book):
return SearchResult(
book.title,
book.local_id,
book.author_text,
book.published_date.year if book.published_date else None,
)
def get_or_create_book(self, remote_id):
''' this COULD be semi-implemented but I think it shouldn't be used '''
pass
def is_work_data(self, data):
pass
def get_edition_from_work_data(self, data):
pass
def get_work_from_edition_date(self, data):
pass
def get_authors_from_data(self, data):
return None
def get_cover_from_data(self, data):
return None
def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it '''
return data
def expand_book_data(self, book):
pass

View File

@ -0,0 +1,3 @@
''' settings book data connectors '''
CONNECTORS = ['openlibrary', 'self_connector', 'fedireads_connector']

152
bookwyrm/forms.py Normal file
View File

@ -0,0 +1,152 @@
''' usin django model forms '''
import datetime
from django.core.exceptions import ValidationError
from django.forms import ModelForm, PasswordInput, widgets
from django import forms
from fedireads import models
class LoginForm(ModelForm):
class Meta:
model = models.User
fields = ['username', 'password']
help_texts = {f: None for f in fields}
widgets = {
'password': PasswordInput(),
}
class RegisterForm(ModelForm):
class Meta:
model = models.User
fields = ['username', 'email', 'password']
help_texts = {f: None for f in fields}
widgets = {
'password': PasswordInput()
}
class RatingForm(ModelForm):
class Meta:
model = models.Review
fields = ['rating']
class ReviewForm(ModelForm):
class Meta:
model = models.Review
fields = ['name', 'content']
help_texts = {f: None for f in fields}
labels = {
'name': 'Title',
'content': 'Review',
}
class CommentForm(ModelForm):
class Meta:
model = models.Comment
fields = ['content']
help_texts = {f: None for f in fields}
labels = {
'content': 'Comment',
}
class QuotationForm(ModelForm):
class Meta:
model = models.Quotation
fields = ['quote', 'content']
help_texts = {f: None for f in fields}
labels = {
'quote': 'Quote',
'content': 'Comment',
}
class ReplyForm(ModelForm):
class Meta:
model = models.Status
fields = ['content']
help_texts = {f: None for f in fields}
labels = {'content': 'Comment'}
class EditUserForm(ModelForm):
class Meta:
model = models.User
fields = ['avatar', 'name', 'summary', 'manually_approves_followers']
help_texts = {f: None for f in fields}
class TagForm(ModelForm):
class Meta:
model = models.Tag
fields = ['name']
help_texts = {f: None for f in fields}
labels = {'name': 'Add a tag'}
class CoverForm(ModelForm):
class Meta:
model = models.Book
fields = ['cover']
help_texts = {f: None for f in fields}
class EditionForm(ModelForm):
class Meta:
model = models.Edition
exclude = [
'created_date',
'updated_date',
'last_sync_date',
'authors',# TODO
'parent_work',
'shelves',
'misc_identifiers',
'subjects',# TODO
'subject_places',# TODO
'connector',
]
class ImportForm(forms.Form):
csv_file = forms.FileField()
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
selected_string = super().value_from_datadict(data, files, name)
if selected_string == 'day':
interval = datetime.timedelta(days=1)
elif selected_string == 'week':
interval = datetime.timedelta(days=7)
elif selected_string == 'month':
interval = datetime.timedelta(days=31) # Close enough?
elif selected_string == 'forever':
return None
else:
return selected_string # "This will raise
return datetime.datetime.now() + interval
class CreateInviteForm(ModelForm):
class Meta:
model = models.SiteInvite
exclude = ['code', 'user', 'times_used']
widgets = {
'expiry': ExpiryWidget(choices=[
('day', 'One Day'),
('week', 'One Week'),
('month', 'One Month'),
('forever', 'Does Not Expire')]),
'use_limit': widgets.Select(
choices=[(i, "%d uses" % (i,)) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, 'Unlimited')])
}

View File

@ -0,0 +1,54 @@
''' handle reading a csv from goodreads '''
import csv
from requests import HTTPError
from fedireads import outgoing
from fedireads.tasks import app
from fedireads.models import ImportJob, ImportItem
from fedireads.status import create_notification
# TODO: remove or increase once we're confident it's not causing problems.
MAX_ENTRIES = 500
def create_job(user, csv_file):
''' check over a csv and creates a database entry for the job'''
job = ImportJob.objects.create(user=user)
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.")
ImportItem(job=job, index=index, data=entry).save()
return job
def start_import(job):
''' initalizes a csv import job '''
result = import_data.delay(job.id)
job.task_id = result.id
job.save()
@app.task
def import_data(job_id):
''' does the actual lookup work in a celery task '''
job = ImportJob.objects.get(id=job_id)
try:
results = []
for item in job.items.all():
try:
item.resolve()
except HTTPError:
pass
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()
finally:
create_notification(job.user, 'IMPORT', related_import=job)

299
bookwyrm/incoming.py Normal file
View File

@ -0,0 +1,299 @@
''' handles all of the activity coming in to the server '''
import json
from urllib.parse import urldefrag
import django.db.utils
from django.http import HttpResponse
from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
import requests
from fedireads import activitypub, books_manager, models, outgoing
from fedireads import status as status_builder
from fedireads.remote_user import get_or_create_remote_user, refresh_remote_user
from fedireads.tasks import app
from fedireads.signatures import Signature
@csrf_exempt
def inbox(request, username):
''' incoming activitypub events '''
# TODO: should do some kind of checking if the user accepts
# this action from the sender probably? idk
# but this will just throw a 404 if the user doesn't exist
try:
models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
return shared_inbox(request)
@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)
except json.decoder.JSONDecodeError:
return HttpResponseBadRequest()
if not activity.get('object'):
return HttpResponseBadRequest()
if not has_valid_signature(request, activity):
if activity['type'] == 'Delete':
# Pretend that unauth'd deletes succeed. Auth may be failing because
# the resource or owner of the resource might have been deleted.
return HttpResponse()
return HttpResponse(status=401)
handlers = {
'Follow': handle_follow,
'Accept': handle_follow_accept,
'Reject': handle_follow_reject,
'Create': handle_create,
'Like': handle_favorite,
'Announce': handle_boost,
'Add': {
'Tag': handle_tag,
},
'Undo': {
'Follow': handle_unfollow,
'Like': handle_unfavorite,
},
'Update': {
'Person': None,# TODO: handle_update_user
'Document': handle_update_book,
},
}
activity_type = activity['type']
handler = handlers.get(activity_type, None)
if isinstance(handler, dict):
handler = handler.get(activity['object']['type'], None)
if not handler:
return HttpResponseNotFound()
handler.delay(activity)
return HttpResponse()
def has_valid_signature(request, activity):
''' verify incoming signature '''
try:
signature = Signature.parse(request)
key_actor = urldefrag(signature.key_id).url
if key_actor != activity.get('actor'):
raise ValueError("Wrong actor created signature.")
remote_user = get_or_create_remote_user(key_actor)
try:
signature.verify(remote_user.public_key, request)
except ValueError:
old_key = remote_user.public_key
refresh_remote_user(remote_user)
if remote_user.public_key == old_key:
raise # Key unchanged.
signature.verify(remote_user.public_key, request)
except (ValueError, requests.exceptions.HTTPError):
return False
return True
@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']
)
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
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
)
@app.task
def handle_unfollow(activity):
''' unfollow a local user '''
obj = activity['object']
requester = get_or_create_remote_user(obj['actor'])
to_unfollow = models.User.objects.get(remote_id=obj['object'])
# raises models.User.DoesNotExist
to_unfollow.followers.remove(requester)
@app.task
def handle_follow_accept(activity):
''' hurray, someone remote accepted a follow request '''
# 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'])
try:
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=accepter
)
request.delete()
except models.UserFollowRequest.DoesNotExist:
pass
accepter.followers.add(requester)
@app.task
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'])
request = models.UserFollowRequest.objects.get(
user_subject=requester,
user_object=rejecter
)
request.delete()
#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
return
user = get_or_create_remote_user(activity['actor'])
if user.local:
# we really oughtn't even be sending in this case
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
).first()
if not reply:
return
model = models.activity_models[activity.type]
status = 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(
status.reply_parent.user,
'REPLY',
related_user=status.user,
related_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
liker = get_or_create_remote_user(activity['actor'])
if liker.local:
return
status = fav.to_model(models.Favorite)
status_builder.create_notification(
status.user,
'FAVORITE',
related_user=liker,
related_status=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()
@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'])
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,
)
@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'])
@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)

View File

@ -0,0 +1,213 @@
# Generated by Django 3.0.3 on 2020-02-19 06:43
from django.conf import settings
import django.contrib.auth.models
import django.contrib.auth.validators
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import fedireads.utils.fields
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('private_key', models.TextField(blank=True, null=True)),
('public_key', models.TextField(blank=True, null=True)),
('actor', models.CharField(max_length=255, unique=True)),
('inbox', models.CharField(max_length=255, unique=True)),
('shared_inbox', models.CharField(blank=True, max_length=255, null=True)),
('outbox', models.CharField(max_length=255, unique=True)),
('summary', models.TextField(blank=True, null=True)),
('local', models.BooleanField(default=True)),
('fedireads_user', models.BooleanField(default=True)),
('localname', models.CharField(max_length=255, null=True, unique=True)),
('name', models.CharField(blank=True, max_length=100, null=True)),
('avatar', models.ImageField(blank=True, null=True, upload_to='avatars/')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Author',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('openlibrary_key', models.CharField(max_length=255)),
('data', fedireads.utils.fields.JSONField()),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Book',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('openlibrary_key', models.CharField(max_length=255, unique=True)),
('data', fedireads.utils.fields.JSONField()),
('cover', models.ImageField(blank=True, null=True, upload_to='covers/')),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('authors', models.ManyToManyField(to='fedireads.Author')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='FederatedServer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('server_name', models.CharField(max_length=255, unique=True)),
('status', models.CharField(default='federated', max_length=255)),
('application_type', models.CharField(max_length=255, null=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Shelf',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('name', models.CharField(max_length=100)),
('identifier', models.CharField(max_length=100)),
('editable', models.BooleanField(default=True)),
],
),
migrations.CreateModel(
name='Status',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('status_type', models.CharField(default='Note', max_length=255)),
('activity_type', models.CharField(default='Note', max_length=255)),
('local', models.BooleanField(default=True)),
('privacy', models.CharField(default='public', max_length=255)),
('sensitive', models.BooleanField(default=False)),
('mention_books', models.ManyToManyField(related_name='mention_book', to='fedireads.Book')),
('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)),
('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='UserRelationship',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('status', models.CharField(default='follows', max_length=100, null=True)),
('relationship_id', models.CharField(max_length=100)),
('user_object', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_object', to=settings.AUTH_USER_MODEL)),
('user_subject', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='user_subject', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ShelfBook',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Shelf')),
],
options={
'unique_together': {('book', 'shelf')},
},
),
migrations.AddField(
model_name='shelf',
name='books',
field=models.ManyToManyField(through='fedireads.ShelfBook', to='fedireads.Book'),
),
migrations.AddField(
model_name='shelf',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='book',
name='shelves',
field=models.ManyToManyField(through='fedireads.ShelfBook', to='fedireads.Shelf'),
),
migrations.AddField(
model_name='user',
name='federated_server',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.FederatedServer'),
),
migrations.AddField(
model_name='user',
name='followers',
field=models.ManyToManyField(through='fedireads.UserRelationship', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
),
migrations.AddField(
model_name='user',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
),
migrations.AlterUniqueTogether(
name='shelf',
unique_together={('user', 'identifier')},
),
migrations.CreateModel(
name='Review',
fields=[
('status_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Status')),
('name', models.CharField(max_length=255)),
('rating', models.IntegerField(default=0, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(5)])),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
],
options={
'abstract': False,
},
bases=('fedireads.status',),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 3.0.3 on 2020-02-19 08:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Favorite',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'status')},
},
),
migrations.AddField(
model_name='status',
name='favorites',
field=models.ManyToManyField(related_name='user_favorites', through='fedireads.Favorite', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='favorites',
field=models.ManyToManyField(related_name='favorite_statuses', through='fedireads.Favorite', to='fedireads.Status'),
),
]

View File

@ -0,0 +1,93 @@
# Generated by Django 3.0.3 on 2020-02-21 01:31
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0002_auto_20200219_0816'),
]
operations = [
migrations.RemoveField(
model_name='author',
name='content',
),
migrations.RemoveField(
model_name='book',
name='content',
),
migrations.RemoveField(
model_name='favorite',
name='content',
),
migrations.RemoveField(
model_name='federatedserver',
name='content',
),
migrations.RemoveField(
model_name='shelf',
name='content',
),
migrations.RemoveField(
model_name='shelfbook',
name='content',
),
migrations.RemoveField(
model_name='userrelationship',
name='content',
),
migrations.AddField(
model_name='author',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='book',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='favorite',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='federatedserver',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='shelf',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='shelfbook',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='status',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='user',
name='created_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='user',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='userrelationship',
name='updated_date',
field=models.DateTimeField(auto_now=True),
),
]

View File

@ -0,0 +1,29 @@
# Generated by Django 3.0.3 on 2020-02-21 05:54
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0003_auto_20200221_0131'),
]
operations = [
migrations.CreateModel(
name='Tag',
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)),
('name', models.CharField(max_length=140)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'book', 'name')},
},
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 3.0.3 on 2020-02-21 16:45
import re
from django.db import migrations, models
def populate_identifiers(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
tags = app_registry.get_model('fedireads', 'Tag')
for tag in tags.objects.using(db_alias):
tag.identifier = re.sub(r'\W+', '-', tag.name).lower()
tag.save()
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0004_tag'),
]
operations = [
migrations.AddField(
model_name='tag',
name='identifier',
field=models.CharField(max_length=100, null=True),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(max_length=100),
),
migrations.RunPython(populate_identifiers),
]

View File

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

View File

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

View File

@ -0,0 +1,19 @@
# 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 = [
('fedireads', '0007_auto_20200223_0902'),
]
operations = [
migrations.AlterField(
model_name='user',
name='followers',
field=models.ManyToManyField(related_name='following', through='fedireads.UserRelationship', to=settings.AUTH_USER_MODEL),
),
]

View File

@ -0,0 +1,19 @@
# 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 = [
('fedireads', '0008_auto_20200224_1504'),
]
operations = [
migrations.AddField(
model_name='status',
name='published_date',
field=models.DateTimeField(default=datetime.datetime.now),
),
]

View File

@ -0,0 +1,190 @@
# 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 fedireads.utils.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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='fedireads.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=('fedireads.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='fedireads.Book')),
('lccn', models.CharField(max_length=255, null=True, unique=True)),
],
options={
'abstract': False,
},
bases=('fedireads.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=fedireads.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=fedireads.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='fedireads.Work'),
),
]

View File

@ -0,0 +1,32 @@
# 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 = [
('fedireads', '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='fedireads.Book')),
('related_status', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.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,19 @@
# Generated by Django 3.0.3 on 2020-03-08 16:25
from django.db import migrations, models
import fedireads.utils.fields
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0011_notification'),
]
operations = [
migrations.AlterField(
model_name='author',
name='aliases',
field=fedireads.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.3 on 2020-03-09 20:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0012_auto_20200308_1625'),
]
operations = [
migrations.AddField(
model_name='user',
name='manually_approves_followers',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.0.3 on 2020-03-10 19:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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

@ -0,0 +1,115 @@
# 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 = [
('fedireads', '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='fedireads.UserBlocks', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='follow_requests',
field=models.ManyToManyField(related_name='follower_requests', through='fedireads.UserFollowRequest', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='following',
field=models.ManyToManyField(related_name='followers', through='fedireads.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,22 @@
# Generated by Django 3.0.3 on 2020-03-13 13:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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,26 @@
# 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 = [
('fedireads', '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,18 @@
# Generated by Django 3.0.3 on 2020-03-21 21:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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,26 @@
# 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 = [
('fedireads', '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='fedireads.Status')),
('name', models.CharField(max_length=255)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
],
options={
'abstract': False,
},
bases=('fedireads.status',),
),
]

View File

@ -0,0 +1,58 @@
# Generated by Django 3.0.3 on 2020-03-27 23:35
from django.db import migrations, models
import django.db.models.deletion
import fedireads.models.book
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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'), ('fedireads', 'Fedireads')], 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=fedireads.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='fedireads.Connector'),
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 3.0.3 on 2020-03-28 04:28
from django.db import migrations, models
import fedireads.utils.fields
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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=fedireads.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, size=None),
),
migrations.AddField(
model_name='book',
name='subjects',
field=fedireads.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=fedireads.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,27 @@
# Generated by Django 3.0.3 on 2020-03-28 20:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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,114 @@
# 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 = [
('fedireads', '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

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

View File

@ -0,0 +1,24 @@
# 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 = [
('fedireads', '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

@ -0,0 +1,42 @@
# 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 = [
('fedireads', '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='fedireads.Status')),
],
options={
'abstract': False,
},
bases=('fedireads.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='fedireads.Status'),
),
]

View File

@ -0,0 +1,82 @@
# Generated by Django 3.0.3 on 2020-03-30 22:32
from django.db import migrations, models
import django.db.models.deletion
import fedireads.utils.fields
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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=fedireads.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='fedireads.Work'),
),
migrations.AddField(
model_name='edition',
name='shelves',
field=models.ManyToManyField(through='fedireads.ShelfBook', to='fedireads.Shelf'),
),
migrations.AlterField(
model_name='comment',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Edition'),
),
migrations.AlterField(
model_name='notification',
name='related_book',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Edition'),
),
migrations.AlterField(
model_name='review',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Edition'),
),
migrations.AlterField(
model_name='shelf',
name='books',
field=models.ManyToManyField(through='fedireads.ShelfBook', to='fedireads.Edition'),
),
migrations.AlterField(
model_name='shelfbook',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Edition'),
),
migrations.AlterField(
model_name='status',
name='mention_books',
field=models.ManyToManyField(related_name='mention_book', to='fedireads.Edition'),
),
migrations.AlterField(
model_name='tag',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Edition'),
),
]

View File

@ -0,0 +1,23 @@
# 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 = [
('fedireads', '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

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

View File

@ -0,0 +1,26 @@
# 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 = [
('fedireads', '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='fedireads.Status')),
('quote', models.TextField()),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Edition')),
],
options={
'abstract': False,
},
bases=('fedireads.status',),
),
]

View File

@ -0,0 +1,31 @@
# 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 = [
('fedireads', '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='fedireads.Book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@ -0,0 +1,60 @@
# 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 fedireads.utils.fields
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0031_readthrough'),
]
operations = [
migrations.CreateModel(
name='ImportItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('data', fedireads.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='fedireads.Book'),
),
migrations.AddField(
model_name='importitem',
name='job',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='fedireads.ImportJob'),
),
]

View File

@ -0,0 +1,43 @@
# 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 = [
('fedireads', '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='fedireads.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

@ -0,0 +1,19 @@
# 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 = [
('fedireads', '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='fedireads.Status'),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 3.0.3 on 2020-04-29 17:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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

@ -0,0 +1,39 @@
# Generated by Django 3.0.3 on 2020-05-03 20:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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

@ -0,0 +1,41 @@
# 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 = [
('fedireads', '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

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

View File

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

View File

@ -0,0 +1,77 @@
# Generated by Django 3.0.3 on 2020-05-13 01:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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

@ -0,0 +1,18 @@
# Generated by Django 3.0.3 on 2020-05-13 02:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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

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

View File

@ -0,0 +1,23 @@
# Generated by Django 3.0.3 on 2020-06-01 18:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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

@ -0,0 +1,24 @@
# Generated by Django 3.0.3 on 2020-06-01 21:31
from django.db import migrations, models
import fedireads.models.site
class Migration(migrations.Migration):
dependencies = [
('fedireads', '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=fedireads.models.site.new_invite_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

@ -0,0 +1,21 @@
# Generated by Django 3.0.3 on 2020-06-02 15:46
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0043_siteinvite'),
]
operations = [
migrations.AddField(
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,
),
]

View File

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

View File

View File

@ -0,0 +1,20 @@
''' bring all the models into the app namespace '''
import inspect
import sys
from .book import Book, Work, Edition, Author
from .connector import Connector
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .shelf import Shelf, ShelfBook
from .status import Status, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough
from .tag import Tag
from .user import User
from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite
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')}

View File

@ -0,0 +1,219 @@
''' base model with default fields '''
from base64 import b64encode
from dataclasses import dataclass
from typing import Callable
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.db import models
from django.dispatch import receiver
from fedireads import activitypub
from fedireads.settings import DOMAIN
class FedireadsModel(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)
def get_remote_id(self):
''' generate a url that resolves to the local object '''
base_path = 'https://%s' % DOMAIN
if hasattr(self, 'user'):
base_path = self.user.remote_id
model_name = type(self).__name__.lower()
return '%s/%s/%d' % (base_path, model_name, self.id)
class Meta:
''' this is just here to provide default fields for other models '''
abstract = True
@receiver(models.signals.post_save)
def execute_after_save(sender, instance, created, *args, **kwargs):
''' set the remote_id after save (when the id is available) '''
if not created or not hasattr(instance, 'get_remote_id'):
return
if not instance.remote_id:
instance.remote_id = instance.get_remote_id()
instance.save()
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
activity_serializer = lambda: {}
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
fields = {}
for mapping in mappings:
if not hasattr(self, mapping.model_key) or not mapping.activity_key:
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()
def to_create_activity(self, user, pure=False):
''' returns the object wrapped in a Create activity '''
activity_object = self.to_activity(pure=pure)
signer = pkcs1_15.new(RSA.import_key(user.private_key))
content = activity_object['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
create_id = self.remote_id + '/activity'
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
object=activity_object,
signature=signature,
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (user.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self.to_activity()
).serialize()
def to_undo_activity(self, user):
''' undo an action '''
return activitypub.Undo(
id='%s#undo' % user.remote_id,
actor=user.remote_id,
object=self.to_activity()
)
class OrderedCollectionPageMixin(ActivitypubMixin):
''' just the paginator utilities, so you don't HAVE to
override ActivitypubMixin's to_activity (ie, for outbox '''
@property
def collection_remote_id(self):
''' 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(
queryset, remote_id, **kwargs)
name = ''
if hasattr(self, 'name'):
name = self.name
size = queryset.count()
return activitypub.OrderedCollection(
id=remote_id,
totalItems=size,
name=name,
first='%s%s' % (remote_id, self.page()),
last='%s%s' % (remote_id, self.page(min_id=0))
).serialize()
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
@property
def collection_queryset(self):
''' usually an ordered collection model aggregates a different model '''
raise NotImplementedError('Model must define collection_queryset')
activity_serializer = activitypub.OrderedCollection
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

224
bookwyrm/models/book.py Normal file
View File

@ -0,0 +1,224 @@
''' database schema for books and shelves '''
from django.db import models
from django.utils import timezone
from django.utils.http import http_date
from model_utils.managers import InheritanceManager
from fedireads import activitypub
from fedireads.settings import DOMAIN
from fedireads.utils.fields import ArrayField
from .base_model import ActivityMapping, ActivitypubMixin, FedireadsModel
class Book(ActivitypubMixin, FedireadsModel):
''' a generic book, which can mean either an edition or a work '''
# 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)
# info about where the data comes from and where/if to sync
sync = models.BooleanField(default=True)
sync_cover = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
connector = models.ForeignKey(
'Connector', on_delete=models.PROTECT, null=True)
# 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(
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
)
subject_places = ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
# TODO: include an annotation about the type of authorship (ie, translator)
authors = models.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)
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)
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__,
self.openlibrary_key,
self.title,
)
class Work(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)
@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
activity_serializer = activitypub.Work
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(
models.CharField(max_length=255), blank=True, default=list
)
shelves = models.ManyToManyField(
'Shelf',
symmetrical=False,
through='ShelfBook',
through_fields=('book', 'shelf')
)
parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True)
activity_serializer = activitypub.Edition
class Author(ActivitypubMixin, FedireadsModel):
''' 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)
@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
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

View File

@ -0,0 +1,40 @@
''' manages interfaces with external sources of book data '''
from django.db import models
from fedireads.connectors.settings import CONNECTORS
from .base_model import FedireadsModel
ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
class Connector(FedireadsModel):
''' 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)
local = models.BooleanField(default=False)
connector_file = models.CharField(
max_length=255,
choices=ConnectorFiles.choices
)
api_key = models.CharField(max_length=255, null=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)
politeness_delay = models.IntegerField(null=True) #seconds
max_query_count = models.IntegerField(null=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)
class Meta:
''' check that there's code to actually use this connector '''
constraints = [
models.CheckConstraint(
check=models.Q(connector_file__in=ConnectorFiles),
name='connector_file_valid'
)
]

View File

@ -0,0 +1,15 @@
''' connections to external ActivityPub servers '''
from django.db import models
from .base_model import FedireadsModel
class FederatedServer(FedireadsModel):
''' store which server's we federate with '''
server_name = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else
status = models.CharField(max_length=255, default='federated')
# is it mastodon, fedireads, etc
application_type = models.CharField(max_length=255, null=True)
application_version = models.CharField(max_length=255, null=True)
# TODO: blocked servers

View File

@ -0,0 +1,121 @@
''' track progress of goodreads imports '''
import re
import dateutil.parser
from django.db import models
from django.utils import timezone
from fedireads import books_manager
from fedireads.models import ReadThrough, User, Book
from fedireads.utils.fields import JSONField
# Mapping goodreads -> fedireads shelf titles.
GOODREADS_SHELVES = {
'read': 'read',
'currently-reading': 'reading',
'to-read': 'to-read',
}
def unquote_string(text):
''' resolve csv quote weirdness '''
match = re.match(r'="([^"]*)"', text)
if match:
return match.group(1)
return text
def construct_search_term(title, author):
''' formulate a query for the data connector '''
# Strip brackets (usually series title from search term)
title = re.sub(r'\s*\([^)]*\)\s*', '', title)
# Open library doesn't like including author initials in search term.
author = re.sub(r'(\w\.)+\s*', '', author)
return ' '.join([title, author])
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)
class ImportItem(models.Model):
job = models.ForeignKey(
ImportJob,
on_delete=models.CASCADE,
related_name='items')
index = models.IntegerField()
data = JSONField()
book = models.ForeignKey(
Book, on_delete=models.SET_NULL, null=True, blank=True)
fail_reason = models.TextField(null=True)
def resolve(self):
''' try various ways to lookup a book '''
self.book = (
self.get_book_from_isbn() or
self.get_book_from_title_author()
)
def get_book_from_isbn(self):
''' search by isbn '''
search_result = books_manager.first_search_result(self.isbn)
if search_result:
return books_manager.get_or_create_book(search_result.key)
def get_book_from_title_author(self):
''' search by title and author '''
search_term = construct_search_term(
self.data['Title'],
self.data['Author']
)
search_result = books_manager.first_search_result(search_term)
if search_result:
return books_manager.get_or_create_book(search_result.key)
@property
def isbn(self):
return unquote_string(self.data['ISBN13'])
@property
def shelf(self):
''' the goodreads shelf field '''
if self.data['Exclusive Shelf']:
return GOODREADS_SHELVES.get(self.data['Exclusive Shelf'])
@property
def review(self):
return self.data['My Review']
@property
def rating(self):
return int(self.data['My Rating'])
@property
def date_added(self):
if self.data['Date Added']:
return dateutil.parser.parse(self.data['Date Added'])
@property
def date_read(self):
if self.data['Date Read']:
return dateutil.parser.parse(self.data['Date Read'])
@property
def reads(self):
if (self.shelf == 'reading'
and self.date_added and not self.date_read):
return [ReadThrough(start_date=self.date_added)]
if self.date_read:
return [ReadThrough(
finish_date=self.date_read,
)]
return []
def __repr__(self):
return "<GoodreadsItem {!r}>".format(self.data['Title'])
def __str__(self):
return "{} by {}".format(self.data['Title'], self.data['Author'])

View File

@ -0,0 +1,89 @@
''' defines relationships between users '''
from django.db import models
from fedireads import activitypub
from .base_model import FedireadsModel
class UserRelationship(FedireadsModel):
''' many-to-many through table for followers '''
user_subject = models.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='%(class)s_user_subject'
)
user_object = models.ForeignKey(
'User',
on_delete=models.PROTECT,
related_name='%(class)s_user_object'
)
# follow or follow_request for pending TODO: blocking?
relationship_id = models.CharField(max_length=100)
class Meta:
''' relationships should be unique '''
abstract = True
constraints = [
models.UniqueConstraint(
fields=['user_subject', 'user_object'],
name='%(class)s_unique'
),
models.CheckConstraint(
check=~models.Q(user_subject=models.F('user_object')),
name='%(class)s_no_self'
)
]
def get_remote_id(self):
''' use shelf identifier in remote_id '''
base_path = self.user_subject.remote_id
return '%s#%s/%d' % (base_path, self.status, self.id)
class UserFollows(UserRelationship):
''' Following a user '''
status = 'follows'
@classmethod
def from_request(cls, follow_request):
''' converts a follow request into a follow relationship '''
return cls(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
relationship_id=follow_request.relationship_id,
)
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 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,
).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,
).serialize()
class UserBlocks(UserRelationship):
''' prevent another user from following you and seeing your posts '''
# TODO: not implemented
status = 'blocks'

69
bookwyrm/models/shelf.py Normal file
View File

@ -0,0 +1,69 @@
''' puttin' books on shelves '''
from django.db import models
from fedireads import activitypub
from .base_model import FedireadsModel, OrderedCollectionMixin
class Shelf(OrderedCollectionMixin, FedireadsModel):
''' a list of books owned by a user '''
name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT)
editable = models.BooleanField(default=True)
books = models.ManyToManyField(
'Edition',
symmetrical=False,
through='ShelfBook',
through_fields=('shelf', 'book')
)
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books
def get_remote_id(self):
''' shelf identifier instead of id '''
base_path = self.user.remote_id
return '%s/shelf/%s' % (base_path, self.identifier)
class Meta:
''' user/shelf unqiueness '''
unique_together = ('user', 'identifier')
class ShelfBook(FedireadsModel):
''' 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(
'User',
blank=True,
null=True,
on_delete=models.PROTECT
)
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()
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
target=self.shelf.to_activity()
).serialize()
class Meta:
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf')

45
bookwyrm/models/site.py Normal file
View File

@ -0,0 +1,45 @@
import base64
from Crypto import Random
from django.db import models
from django.utils import timezone
import datetime
from fedireads.settings import DOMAIN
from .user import User
class SiteSettings(models.Model):
name = models.CharField(default=DOMAIN, 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)
@classmethod
def get(cls):
try:
return cls.objects.get(id=1)
except cls.DoesNotExist:
default_settings = SiteSettings(id=1)
default_settings.save()
return default_settings
def new_invite_code():
return base64.b32encode(Random.get_random_bytes(5)).decode('ascii')
class SiteInvite(models.Model):
code = models.CharField(max_length=32, default=new_invite_code)
expiry = models.DateTimeField(blank=True, null=True)
use_limit = models.IntegerField(blank=True, null=True)
times_used = models.IntegerField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE)
def valid(self):
return (
(self.expiry is None or self.expiry > timezone.now()) and
(self.use_limit is None or self.times_used < self.use_limit))
@property
def link(self):
return "https://{}/invite/{}".format(DOMAIN, self.code)

246
bookwyrm/models/status.py Normal file
View File

@ -0,0 +1,246 @@
''' 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 fedireads import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import ActivityMapping, FedireadsModel
class Status(OrderedCollectionPageMixin, FedireadsModel):
''' 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')
local = models.BooleanField(default=True)
privacy = models.CharField(max_length=255, default='public')
sensitive = models.BooleanField(default=False)
# the created date can't be this, because of receiving federated posts
published_date = models.DateTimeField(default=timezone.now)
favorites = models.ManyToManyField(
'User',
symmetrical=False,
through='Favorite',
through_fields=('status', 'user'),
related_name='user_favorites'
)
reply_parent = models.ForeignKey(
'self',
null=True,
on_delete=models.PROTECT
)
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 fedireads 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', 'pure_ap_name'),
ActivityMapping('content', 'ap_pure_content'),
]
activity_serializer = activitypub.Note
#----- replies collection activitypub ----#
@classmethod
def replies(cls, status):
''' load all replies to a status. idk if there's a better way
to write this so it's just a property '''
return cls.objects.filter(reply_parent=status).select_subclasses()
def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status '''
return self.to_ordered_collection(
self.replies(self),
remote_id='%s/replies' % self.remote_id,
**kwargs
)
class Comment(Status):
''' like a review but without a rating and transient '''
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
@property
def ap_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)
activity_serializer = activitypub.Comment
pure_activity_serializer = activitypub.Note
class Quotation(Status):
''' like a review but without a rating and transient '''
quote = models.TextField()
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
@property
def ap_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' % (
self.quote,
self.book.local_id,
self.book.title,
self.content,
)
activity_serializer = activitypub.Quotation
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(
default=None,
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
@property
def ap_pure_name(self):
''' clarify review names for mastodon serialization '''
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
self.name
)
@property
def ap_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)
activity_serializer = activitypub.Review
class Favorite(ActivitypubMixin, FedireadsModel):
''' 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'),
]
activity_serializer = activitypub.Like
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')
class Boost(Status):
''' boost'ing a post '''
boosted_status = models.ForeignKey(
'Status',
on_delete=models.PROTECT,
related_name="boosters")
activity_mappings = [
ActivityMapping('id', 'remote_id'),
ActivityMapping('actor', 'user'),
ActivityMapping('object', 'boosted_status'),
]
activity_serializer = activitypub.Like
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
class ReadThrough(FedireadsModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Book', on_delete=models.PROTECT)
pages_read = models.IntegerField(
null=True,
blank=True)
start_date = models.DateTimeField(
blank=True,
null=True)
finish_date = models.DateTimeField(
blank=True,
null=True)
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
class Notification(FedireadsModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
related_book = models.ForeignKey(
'Edition', on_delete=models.PROTECT, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.PROTECT, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.PROTECT, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.PROTECT, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
class Meta:
''' checks if notifcation is in enum list for valid types '''
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
)
]

60
bookwyrm/models/tag.py Normal file
View File

@ -0,0 +1,60 @@
''' models for storing different kinds of Activities '''
import urllib.parse
from django.db import models
from fedireads import activitypub
from fedireads.settings import DOMAIN
from .base_model import OrderedCollectionMixin, FedireadsModel
class Tag(OrderedCollectionMixin, FedireadsModel):
''' 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)
identifier = models.CharField(max_length=100)
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(identifier=identifier)
@property
def collection_queryset(self):
''' books associated with this tag '''
return self.book_queryset(self.identifier)
def get_remote_id(self):
''' tag should use identifier not id in remote_id '''
base_path = 'https://%s' % DOMAIN
return '%s/tag/%s' % (base_path, self.identifier)
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(),
).serialize()
def to_remove_activity(self, user):
''' AP for un-shelving a book'''
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=user.remote_id,
object=self.book.to_activity(),
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')

218
bookwyrm/models/user.py Normal file
View File

@ -0,0 +1,218 @@
''' database schema for user data '''
from urllib.parse import urlparse
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.dispatch import receiver
from fedireads import activitypub
from fedireads.models.shelf import Shelf
from fedireads.models.status import Status
from fedireads.settings import DOMAIN
from fedireads.signatures import create_key_pair
from .base_model import OrderedCollectionPageMixin
from .base_model import ActivityMapping
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)
federated_server = models.ForeignKey(
'FederatedServer',
on_delete=models.PROTECT,
null=True,
)
outbox = models.CharField(max_length=255, unique=True)
summary = models.TextField(blank=True, null=True)
local = models.BooleanField(default=True)
fedireads_user = models.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(
'self',
symmetrical=False,
through='UserFollows',
through_fields=('user_subject', 'user_object'),
related_name='followers'
)
follow_requests = models.ManyToManyField(
'self',
symmetrical=False,
through='UserFollowRequest',
through_fields=('user_subject', 'user_object'),
related_name='follower_requests'
)
blocks = models.ManyToManyField(
'self',
symmetrical=False,
through='UserBlocks',
through_fields=('user_subject', 'user_object'),
related_name='blocked_by'
)
favorites = models.ManyToManyField(
'Status',
symmetrical=False,
through='Favorite',
through_fields=('user', 'status'),
related_name='favorite_statuses'
)
remote_id = models.CharField(max_length=255, null=True, unique=True)
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
@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 = '%s/static/images/default_avi.jpg' % DOMAIN
media_type = 'image/jpeg'
return activitypub.Image(media_type, url, 'Image')
@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()
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, \
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, \
remote_id=remote_id, id_only=True, **kwargs)
def to_activity(self, pure=False):
''' override default AP serializer to add context object
idk if this is the best way to go about this '''
activity_object = super().to_activity()
activity_object['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
{
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
'schema': 'http://schema.org#',
'PropertyValue': 'schema:PropertyValue',
'value': 'schema:value',
}
]
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
# 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()
@receiver(models.signals.post_save, sender=User)
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
if not instance.local or not created:
return
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=instance,
editable=False
).save()

323
bookwyrm/outgoing.py Normal file
View File

@ -0,0 +1,323 @@
''' handles all the activity coming out of the server '''
from datetime import datetime
from django.db import IntegrityError, transaction
from django.http import HttpResponseNotFound, JsonResponse
from django.views.decorators.csrf import csrf_exempt
import requests
from fedireads import activitypub
from fedireads import models
from fedireads.broadcast import broadcast
from fedireads.status import create_review, create_status
from fedireads.status import create_quotation, create_comment
from fedireads.status import create_tag, create_notification, create_rating
from fedireads.remote_user import get_or_create_remote_user
@csrf_exempt
def outbox(request, username):
''' outbox for the requested user '''
if request.method != 'GET':
return HttpResponseNotFound()
try:
user = models.User.objects.get(localname=username)
except models.User.DoesNotExist:
return HttpResponseNotFound()
# collection overview
return JsonResponse(
user.to_outbox(**request.GET),
encoder=activitypub.ActivityEncoder
)
def handle_account_search(query):
''' webfingerin' other servers '''
user = None
domain = query.split('@')[1]
try:
user = models.User.objects.get(username=query)
except models.User.DoesNotExist:
url = 'https://%s/.well-known/webfinger?resource=acct:%s' % \
(domain, query)
response = requests.get(url)
if not response.ok:
response.raise_for_status()
data = response.json()
for link in data['links']:
if link['rel'] == 'self':
try:
user = get_or_create_remote_user(link['href'])
except KeyError:
return HttpResponseNotFound()
return user
def handle_follow(user, to_follow):
''' someone local wants to follow someone '''
try:
relationship, _ = models.UserFollowRequest.objects.get_or_create(
user_subject=user,
user_object=to_follow,
)
except IntegrityError as err:
if err.__cause__.diag.constraint_name != 'userfollowrequest_unique':
raise
activity = relationship.to_activity()
broadcast(user, activity, direct_recipients=[to_follow])
def handle_unfollow(user, to_unfollow):
''' someone local wants to follow someone '''
relationship = models.UserFollows.objects.get(
user_subject=user,
user_object=to_unfollow
)
activity = relationship.to_undo_activity(user)
broadcast(user, activity, direct_recipients=[to_unfollow])
to_unfollow.followers.remove(user)
def handle_accept(user, to_follow, follow_request):
''' send an acceptance message to a follow request '''
with transaction.atomic():
relationship = models.UserFollows.from_request(follow_request)
follow_request.delete()
relationship.save()
activity = relationship.to_accept_activity()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_reject(user, to_follow, relationship):
''' a local user who managed follows rejects a follow request '''
activity = relationship.to_reject_activity(user)
relationship.delete()
broadcast(to_follow, activity, privacy='direct', direct_recipients=[user])
def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
shelve = models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
broadcast(user, shelve.to_add_activity(user))
# tell the world about this cool thing that happened
verb = {
'to-read': 'wants to read',
'reading': 'started reading',
'read': 'finished reading'
}[shelf.identifier]
message = '%s "%s"' % (verb, book.title)
status = create_status(user, message, mention_books=[book])
status.status_type = 'Update'
status.save()
if shelf.identifier == 'reading':
read = models.ReadThrough(
user=user,
book=book,
start_date=datetime.now())
read.save()
elif shelf.identifier == 'read':
read = models.ReadThrough.objects.filter(
user=user,
book=book,
finish_date=None).order_by('-created_date').first()
if not read:
read = models.ReadThrough(
user=user,
book=book,
start_date=datetime.now())
read.finish_date = datetime.now()
read.save()
broadcast(user, status.to_create_activity(user))
def handle_unshelve(user, book, shelf):
''' a local user is getting a book put on their shelf '''
# update the database
row = models.ShelfBook.objects.get(book=book, shelf=shelf)
activity = row.to_remove_activity(user)
row.delete()
broadcast(user, activity)
def handle_import_books(user, items):
''' process a goodreads csv and then post about it '''
new_books = []
for item in items:
if item.shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
continue
shelf_book, created = models.ShelfBook.objects.get_or_create(
book=item.book, shelf=desired_shelf, added_by=user)
if created:
new_books.append(item.book)
activity = shelf_book.to_add_activity(user)
broadcast(user, activity)
if item.rating or item.review:
review_title = "Review of {!r} on Goodreads".format(
item.book.title,
) if item.review else ""
handle_review(
user,
item.book,
review_title,
item.review,
item.rating,
)
for read in item.reads:
read.book = item.book
read.user = user
read.save()
if new_books:
message = 'imported {} books'.format(len(new_books))
status = create_status(user, message, mention_books=new_books)
status.status_type = 'Update'
status.save()
broadcast(user, status.to_create_activity(user))
return status
return None
def handle_rate(user, book, rating):
''' a review that's just a rating '''
builder = create_rating
handle_status(user, book, builder, rating)
def handle_review(user, book, name, content, rating):
''' post a review '''
# validated and saves the review in the database so it has an id
builder = create_review
handle_status(user, book, builder, name, content, rating)
def handle_quotation(user, book, content, quote):
''' post a review '''
# validated and saves the review in the database so it has an id
builder = create_quotation
handle_status(user, book, builder, content, quote)
def handle_comment(user, book, content):
''' post a comment '''
# validated and saves the review in the database so it has an id
builder = create_comment
handle_status(user, book, builder, content)
def handle_status(user, book_id, builder, *args):
''' generic handler for statuses '''
book = models.Edition.objects.get(id=book_id)
status = builder(user, book, *args)
broadcast(user, status.to_create_activity(user), software='fedireads')
# re-format the activity for non-fedireads servers
remote_activity = status.to_create_activity(user, pure=True)
broadcast(user, remote_activity, software='other')
def handle_tag(user, book, name):
''' tag a book '''
tag = create_tag(user, book, name)
broadcast(user, tag.to_add_activity(user))
def handle_untag(user, book, name):
''' tag a book '''
book = models.Book.objects.get(id=book)
tag = models.Tag.objects.get(name=name, book=book, user=user)
tag_activity = tag.to_remove_activity(user)
tag.delete()
broadcast(user, tag_activity)
def handle_reply(user, review, content):
''' respond to a review or status '''
# validated and saves the comment in the database so it has an id
reply = create_status(user, content, reply_parent=review)
if reply.reply_parent:
create_notification(
reply.reply_parent.user,
'REPLY',
related_user=user,
related_status=reply,
)
broadcast(user, reply.to_create_activity(user))
def handle_favorite(user, status):
''' a user likes a status '''
try:
favorite = models.Favorite.objects.create(
status=status,
user=user
)
except IntegrityError:
# you already fav'ed that
return
fav_activity = favorite.to_activity()
broadcast(
user, fav_activity, privacy='direct', direct_recipients=[status.user])
def handle_unfavorite(user, status):
''' a user likes a status '''
try:
favorite = models.Favorite.objects.get(
status=status,
user=user
)
except models.Favorite.DoesNotExist:
# can't find that status, idk
return
fav_activity = activitypub.Undo(actor=user, object=favorite)
broadcast(user, fav_activity, direct_recipients=[status.user])
def handle_boost(user, status):
''' a user wishes to boost a status '''
if models.Boost.objects.filter(
boosted_status=status, user=user).exists():
# you already boosted that.
return
boost = models.Boost.objects.create(
boosted_status=status,
user=user,
)
boost.save()
boost_activity = boost.to_activity()
broadcast(user, boost_activity)
def handle_update_book(user, book):
''' broadcast the news about our book '''
broadcast(user, book.to_update_activity(user))
def handle_update_user(user):
''' broadcast editing a user's profile '''
broadcast(user, user.to_update_activity())

129
bookwyrm/remote_user.py Normal file
View File

@ -0,0 +1,129 @@
''' manage remote users '''
from urllib.parse import urlparse
from uuid import uuid4
import requests
from django.core.files.base import ContentFile
from django.db import transaction
from fedireads import activitypub, models
def get_or_create_remote_user(actor):
''' look up a remote user or add them '''
try:
return models.User.objects.get(remote_id=actor)
except models.User.DoesNotExist:
pass
data = fetch_user_data(actor)
actor_parts = urlparse(actor)
with transaction.atomic():
user = create_remote_user(data)
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
user.save()
avatar = get_avatar(data)
if avatar:
user.avatar.save(*avatar)
if user.fedireads_user:
get_remote_reviews(user)
return user
def fetch_user_data(actor):
''' load the user's info from the actor url '''
response = requests.get(
actor,
headers={'Accept': 'application/activity+json'}
)
if not response.ok:
response.raise_for_status()
data = response.json()
# make sure our actor is who they say they are
if actor != data['id']:
raise ValueError("Remote actor id must match url.")
return data
def create_remote_user(data):
''' parse the activitypub actor data into a user '''
actor = activitypub.Person(**data)
return actor.to_model(models.User)
def refresh_remote_user(user):
''' get updated user data from its home instance '''
data = fetch_user_data(user.remote_id)
activity = activitypub.Person(**data)
activity.to_model(models.User, instance=user)
def get_avatar(data):
''' find the icon attachment and load the image from the remote sever '''
icon_blob = data.get('icon')
if not icon_blob or not icon_blob.get('url'):
return None
response = requests.get(icon_blob['url'])
if not response.ok:
return None
image_name = str(uuid4()) + '.' + icon_blob['url'].split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
def get_remote_reviews(user):
''' ingest reviews by a new remote fedireads user '''
outbox_page = user.outbox + '?page=true'
response = requests.get(
outbox_page,
headers={'Accept': 'application/activity+json'}
)
data = response.json()
# TODO: pagination?
for status in data['orderedItems']:
if status.get('fedireadsType') == 'Review':
activitypub.Review(**status).to_model(models.Review)
def get_or_create_remote_server(domain):
''' get info on a remote server '''
try:
return models.FederatedServer.objects.get(
server_name=domain
)
except models.FederatedServer.DoesNotExist:
pass
response = requests.get(
'https://%s/.well-known/nodeinfo' % domain,
headers={'Accept': 'application/activity+json'}
)
if response.status_code != 200:
return None
data = response.json()
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
response = requests.get(
nodeinfo_url,
headers={'Accept': 'application/activity+json'}
)
data = response.json()
server = models.FederatedServer.objects.create(
server_name=domain,
application_type=data['software']['name'],
application_version=data['software']['version'],
)
return server

View File

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

52
bookwyrm/sanitize_html.py Normal file
View File

@ -0,0 +1,52 @@
''' html parser to clean up incoming text from unknown sources '''
from html.parser import HTMLParser
class InputHtmlParser(HTMLParser):
''' Removes any html that isn't whitelisted from a block '''
def __init__(self):
HTMLParser.__init__(self)
self.whitelist = ['p', 'b', 'i', 'pre', 'a', 'span']
self.tag_stack = []
self.output = []
# if the html appears invalid, we just won't allow any at all
self.allow_html = True
def handle_starttag(self, tag, attrs):
''' check if the tag is valid '''
if self.allow_html and tag in self.whitelist:
self.output.append(('tag', self.get_starttag_text()))
self.tag_stack.append(tag)
else:
self.output.append(('data', ''))
def handle_endtag(self, tag):
''' keep the close tag '''
if not self.allow_html or tag not in self.whitelist:
self.output.append(('data', ''))
return
if not self.tag_stack or self.tag_stack[-1] != tag:
# the end tag doesn't match the most recent start tag
self.allow_html = False
self.output.append(('data', ''))
return
self.tag_stack = self.tag_stack[:-1]
self.output.append(('tag', '</%s>' % tag))
def handle_data(self, data):
''' extract the answer, if we're in an answer tag '''
self.output.append(('data', data))
def get_output(self):
''' convert the output from a list of tuples to a string '''
if self.tag_stack:
self.allow_html = False
if not self.allow_html:
return ''.join(v for (k, v) in self.output if k == 'data')
return ''.join(v for (k, v) in self.output)

145
bookwyrm/settings.py Normal file
View File

@ -0,0 +1,145 @@
''' fedireads settings and configuration '''
import os
from environs import Env
env = Env()
# celery
CELERY_BROKER = env('CELERY_BROKER')
CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND')
CELERY_ACCEPT_CONTENT = ['application/json']
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = env('SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env.bool('DEBUG', True)
DOMAIN = env('DOMAIN')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', ['*'])
OL_URL = env('OL_URL')
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'fedireads',
'celery',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'fedireads.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'fedireads.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.0/ref/settings/#databases
FEDIREADS_DATABASE_BACKEND = env('FEDIREADS_DATABASE_BACKEND', 'postgres')
FEDIREADS_DBS = {
'postgres': {
'ENGINE': 'django.db.backends.postgresql_psycopg2',
'NAME': env('POSTGRES_DB', 'fedireads'),
'USER': env('POSTGRES_USER', 'fedireads'),
'PASSWORD': env('POSTGRES_PASSWORD', 'fedireads'),
'HOST': env('POSTGRES_HOST', ''),
'PORT': 5432
},
'sqlite': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'fedireads.db')
}
}
DATABASES = {
'default': FEDIREADS_DBS[FEDIREADS_DATABASE_BACKEND]
}
LOGIN_URL = '/login/'
AUTH_USER_MODEL = 'fedireads.User'
# Password validation
# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, env('STATIC_ROOT', 'static'))
MEDIA_URL = '/images/'
MEDIA_ROOT = os.path.join(BASE_DIR, env('MEDIA_ROOT', 'images'))

111
bookwyrm/signatures.py Normal file
View File

@ -0,0 +1,111 @@
import hashlib
from urllib.parse import urlparse
import datetime
from base64 import b64encode, b64decode
from Crypto import Random
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15 #pylint: disable=no-name-in-module
from Crypto.Hash import SHA256
MAX_SIGNATURE_AGE = 300
def create_key_pair():
random_generator = Random.new().read
key = RSA.generate(1024, random_generator)
private_key = key.export_key().decode('utf8')
public_key = key.publickey().export_key().decode('utf8')
return private_key, public_key
def make_signature(sender, destination, date, digest):
inbox_parts = urlparse(destination)
signature_headers = [
'(request-target): post %s' % inbox_parts.path,
'host: %s' % inbox_parts.netloc,
'date: %s' % date,
'digest: %s' % digest,
]
message_to_sign = '\n'.join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.private_key))
signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8')))
signature = {
'keyId': '%s#main-key' % sender.remote_id,
'algorithm': 'rsa-sha256',
'headers': '(request-target) host date digest',
'signature': b64encode(signed_message).decode('utf8'),
}
return ','.join('%s="%s"' % (k, v) for (k, v) in signature.items())
def make_digest(data):
return 'SHA-256=' + b64encode(hashlib.sha256(data.encode('utf-8')).digest()).decode('utf-8')
def verify_digest(request):
algorithm, digest = request.headers['digest'].split('=', 1)
if algorithm == 'SHA-256':
hash_function = hashlib.sha256
elif algorithm == 'SHA-512':
hash_function = hashlib.sha512
else:
raise ValueError("Unsupported hash function: {}".format(algorithm))
expected = hash_function(request.body).digest()
if b64decode(digest) != expected:
raise ValueError("Invalid HTTP Digest header")
class Signature:
def __init__(self, key_id, headers, signature):
self.key_id = key_id
self.headers = headers
self.signature = signature
@classmethod
def parse(cls, request):
signature_dict = {}
for pair in request.headers['Signature'].split(','):
k, v = pair.split('=', 1)
v = v.replace('"', '')
signature_dict[k] = v
try:
key_id = signature_dict['keyId']
headers = signature_dict['headers']
signature = b64decode(signature_dict['signature'])
except KeyError:
raise ValueError('Invalid auth header')
return cls(key_id, headers, signature)
def verify(self, public_key, request):
''' verify rsa signature '''
if http_date_age(request.headers['date']) > MAX_SIGNATURE_AGE:
raise ValueError(
"Request too old: %s" % (request.headers['date'],))
public_key = RSA.import_key(public_key)
comparison_string = []
for signed_header_name in self.headers.split(' '):
if signed_header_name == '(request-target)':
comparison_string.append(
'(request-target): post %s' % request.path)
else:
if signed_header_name == 'digest':
verify_digest(request)
comparison_string.append('%s: %s' % (
signed_header_name,
request.headers[signed_header_name]
))
comparison_string = '\n'.join(comparison_string)
signer = pkcs1_15.new(public_key)
digest = SHA256.new()
digest.update(comparison_string.encode())
# raises a ValueError if it fails
signer.verify(digest, self.signature)
def http_date_age(datestr):
parsed = datetime.datetime.strptime(datestr, '%a, %d %b %Y %H:%M:%S GMT')
delta = datetime.datetime.utcnow() - parsed
return delta.total_seconds()

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Binary file not shown.

822
bookwyrm/static/format.css Normal file
View File

@ -0,0 +1,822 @@
/* some colors that are okay: #247BA0 #70C1B2 #B2DBBF #F3FFBD #FF1654 */
/* general override */
* {
margin: 0;
padding: 0;
line-height: 1.3em;
font-family: sans-serif;
}
html {
background-color: #FFF;
color: black;
}
a {
color: #247BA0;
}
h1 {
font-weight: normal;
font-size: 1.5rem;
}
h2 {
font-weight: normal;
font-size: 1rem;
padding: 0.5rem 0.2rem;
margin-bottom: 1rem;
border-bottom: 3px solid #B2DBBF;
}
h2 .edit-link {
text-decoration: none;
font-size: 0.9em;
float: right;
}
h2 .edit-link .icon {
font-size: 1.2em;
}
h3 {
font-size: 1rem;
font-weight: bold;
margin-bottom: 0.5em;
}
h3 small {
font-weight: normal;
}
section {
margin-bottom: 1em;
}
/* fixed display top bar */
body {
padding-top: 90px;
}
#top-bar {
overflow: visible;
padding: 0.5rem;
border-bottom: 3px solid #247BA0;
margin-bottom: 1em;
width: 100%;
background-color: #FFF;
position: fixed;
top: 0;
height: 47px;
z-index: 2;
}
/* --- header bar content */
#branding {
flex-grow: 0;
}
#menu {
list-style: none;
text-align: center;
margin-top: 1.5rem;
flex-grow: 2;
font-size: 0.9em;
}
#menu li {
display: inline-block;
padding: 0 0.5em;
text-transform: uppercase;
}
#menu a {
color: #555;
text-decoration: none;
font-size: 0.9em;
}
#actions {
margin-top: 1em;
}
#actions > * {
display: inline-block;
}
#actions > *:last-child {
margin-left: 0.5em;
}
#notifications .icon {
font-size: 1.1rem;
}
#notifications a {
color: black;
text-decoration: none;
position: relative;
top: 0.2rem;
}
#notifications .count {
background-color: #FF1654;
color: white;
font-size: 0.85rem;
border-radius: 50%;
display: block;
position: absolute;
text-align: center;
top: -0.65rem;
right: -0.5rem;
height: 1rem;
width: 1rem;
}
.notification {
margin-bottom: 1em;
padding: 1em 0;
background-color: #EEE;
}
.notification.unread {
background-color: #DDD;
}
#search button {
border: none;
background: none;
}
#main, header {
margin: 0 auto;
max-width: 55rem;
padding-right: 1em;
}
/* pulldown */
.pulldown-container {
position: relative;
display: inline;
}
.pulldown {
display: none;
position: absolute;
list-style: none;
background: white;
padding: 1em;
right: 0;
font-size: 0.9rem;
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
width: max-content;
text-align: left;
z-index: 1;
}
.pulldown-container:hover .pulldown {
display: block;
}
.pulldown li a {
display: block;
margin-bottom: 0.5em;
text-decoration: none;
padding: 0.3em 0.8em
}
div.pulldown-button {
background-color: #eee;
border-radius: 0.3em;
color: #247BA0;
width: max-content;
margin: 0 auto;
white-space: nowrap;
}
.post div.pulldown-button {
border: 2px solid #247BA0;
}
.pulldown-button form {
display: inline;
}
div.pulldown-button button {
display: inline;
border: none;
border-radius: 0;
background-color: inherit;
color: #247BA0;
}
div.pulldown-button .pulldown-toggle {
padding-right: 0;
padding-left: 0;
position: relative;
left: -0.5em;
}
ul.pulldown button {
display: block;
text-align: left;
width: 100%;
border: none;
border-radius: 0;
background-color: white;
color: #247BA0;
}
.pulldown button[disabled] {
color: #aaa;
}
.pulldown button[disabled]:hover {
background-color: white;
}
.pulldown button:hover, .pulldown li:hover {
background-color: #ddd;
}
/* content area */
.content-container {
margin: 1rem;
}
.content-container > * {
padding-left: 1em;
padding-right: 1em;
}
#feed {
display: flex;
flex-direction: column;
padding-top: 70px;
position: relative;
z-index: 0;
margin-top: -2em;
}
/* row component */
.row {
display: flex;
flex-direction: row;
}
.row > * {
flex-grow: 1;
width: min-content;
margin-right: 1em;
}
.row > *:last-child {
margin-right: 0;
}
.row.shrink > * {
flex-grow: 0;
width: max-content;
}
.row.wrap {
flex-wrap: wrap;
}
.column {
display: flex;
flex-direction: column;
}
.column > * {
margin-bottom: 1em;
}
/* discover books page grid of covers */
.book-grid .book-cover {
height: 176px;
width: auto;
margin: 0 auto;
}
.book-grid .no-cover {
width: 115px;
}
.book-grid > * {
margin-bottom: 2em;
}
/* special case forms */
.review-form label {
display: block;
}
.review-form textarea {
width: 30rem;
height: 10rem;
}
.review-form.quote-form textarea#id_content {
height: 4rem;
}
.follow-requests .row {
margin-bottom: 0.5em;
}
.follow-requests .row > *:first-child {
width: 20em;
}
.login form {
margin-top: 1em;
}
.login form p {
display: flex;
flex-direction: row;
padding: 0.5em 0;
}
.login form label {
width: 0;
flex-grow: 1;
display: inline-block;
}
.book-form textarea {
display: block;
width: 100%;
font-size: 0.9em;
}
.book-form label {
display: inline-block;
width: 8rem;
vertical-align: top;
}
.book-form .row label {
width: max-content;
}
/* general form stuff */
input, button {
padding: 0.2em 0.5em;
}
button, input[type="submit"] {
cursor: pointer;
width: max-content;
}
.content-container button {
border: none;
background-color: #247BA0;
color: white;
padding: 0.3em 0.8em;
font-size: 0.9em;
border-radius: 0.3em;
}
button.secondary {
background-color: #EEE;
color: #247BA0;
}
.post button.secondary {
border: 2px solid #247BA0;
}
button.warning {
background-color: #FF1654;
}
form input {
flex-grow: 1;
}
form div {
margin-bottom: 1em;
}
textarea {
padding: 0.5em;
}
/* icons */
a .icon {
color: black;
text-decoration: none;
}
button .icon {
font-size: 1.1rem;
vertical-align: sub;
}
.hidden-text {
height: 0;
width: 0;
position: absolute;
overflow: hidden;
}
/* star ratings */
.stars {
letter-spacing: -0.15em;
display: inline-block;
}
.rate-stars .icon {
cursor: pointer;
color: goldenrod;
}
.rate-stars label.icon {
color: black;
}
.rate-stars form {
display: inline;
width: min-content;
}
.rate-stars button.icon {
background: none;
border: none;
padding: 0;
margin: 0;
display: inline;
}
.cover-container .stars {
display: block;
text-align: center;
}
.rate-stars:hover .icon:before {
content: '\e9d9';
}
.rate-stars form:hover ~ form .icon:before{
content: '\e9d7';
}
.review-form .rate-stars:hover .icon:before {
content: '\e9d9';
}
.review-form .rate-stars label {
display: inline;
}
.review-form .rate-stars input + .icon:before {
content: '\e9d9';
}
.review-form .rate-stars input:checked + .icon:before {
content: '\e9d9';
}
.review-form .rate-stars input:checked + * ~ .icon:before {
content: '\e9d7';
}
.review-form .rate-stars:hover label.icon:before {
content: '\e9d9';
}
.review-form .rate-stars label.icon:hover:before {
content: '\e9d9';
}
.review-form .rate-stars label.icon:hover ~ label.icon:before{
content: '\e9d7';
}
.review-form .rate-stars input[type="radio"] {
display: none;
}
/* re-usable tab styles */
.tabs {
display: flex;
flex-direction: row;
border-bottom: 3px solid #FF1654;
padding-left: 1em;
}
.tabs.secondary {
border-bottom: 3px solid #247BA0;
}
.tab {
padding: 0.5em 1em;
border-radius: 0.25em 0.25em 0 0;
}
.secondary .tab {
padding: 0.25em 0.5em;
}
.tabs .tab.active {
background-color: #FF1654;
}
.tabs.secondary .tab.active {
background-color: #247BA0;
}
.tab.active a {
color: black;
}
.user-pic {
width: 2em;
height: 2em;
border-radius: 50%;
vertical-align: top;
position: relative;
bottom: 0.35em;
}
.user-pic.large {
width: 5em;
height: 5em;
}
.user-profile .row > * {
flex-grow: 0;
}
.user-profile .row > *:last-child {
flex-grow: 1;
margin-left: 2em;
}
/* general book display */
.book-preview {
overflow: hidden;
z-index: 1;
text-align: center;
}
.book-preview.grid {
float: left;
}
.cover-container {
flex-grow: 0;
}
.cover-container button {
display: block;
margin: 0 auto;
}
.book-cover {
width: 180px;
height: auto;
}
.book-cover.small {
width: 50px;
height: auto;
}
.no-cover {
position: relative;
}
.no-cover div {
position: absolute;
padding: 1em;
color: white;
top: 0;
left: 0;
text-align: center;
}
.no-cover .title {
text-transform: uppercase;
margin-bottom: 1em;
}
dl {
font-size: 0.9em;
margin-top: 0.5em;
}
dt {
float: left;
margin-right: 0.5em;
}
dd {
margin-bottom: 0.25em;
}
.all-shelves {
display: flex;
flex-direction: row;
margin-left: 0;
position: relative;
z-index: 1;
overflow-y: auto;
}
.all-shelves h2 {
white-space: nowrap;
}
.all-shelves > div {
flex-grow: 0;
}
.all-shelves > div:last-child {
padding-right: 0;
flex-grow: 1;
}
.all-shelves > div > * {
padding: 0;
}
.all-shelves > div:first-child > * {
padding-left: 1em;
}
.covers-shelf {
display: flex;
flex-direction: row;
}
.covers-shelf .cover-container {
margin-right: 1em;
font-size: 0.9em;
overflow: unset;
width: min-content;
}
.covers-shelf .cover-container:last-child {
margin-right: 0;
}
.covers-shelf .book-cover:hover {
cursor: pointer;
box-shadow: #F3FFBD 0em 0em 1em 1em;
}
.covers-shelf .book-cover {
height: 11rem;
width: auto;
margin: 0;
}
.close {
float: right;
cursor: pointer;
padding: 1rem;
}
.all-shelves input[type='radio'] {
display: none;
}
.compose-popout input[type="radio"] {
display: none;
}
.compose-suggestion {
display: none;
box-shadow: 0 5px 10px rgba(0,0,0,0.15);
padding-bottom: 1em;
margin-top: 2em;
}
input:checked ~ .compose-suggestion {
display: block;
}
.compose .book-preview {
background-color: #EEE;
padding: 1em;
}
.compose button {
margin: 0;
}
.compose .stars {
text-align: left;
}
.tag {
display: inline-block;
padding: 0.2em;
border-radius: 0.2em;
background-color: #EEE;
}
.tag form {
display: inline;
}
.tag a {
text-decoration: none;
}
blockquote {
white-space: pre-line;
}
blockquote .icon-quote-open, blockquote .icon-quote-close, .quote blockquote:before, .quote blockquote:after {
font-size: 2rem;
margin-right: 0.5rem;
color: #888;
}
blockquote .icon-quote-open {
float: left;
}
.quote {
margin-bottom: 2em;
position: relative;
}
.quote blockquote {
background-color: white;
margin: 1em;
padding: 1em;
}
.quote blockquote:before, .quote blockquote:after {
font-family: 'icomoon';
position: absolute;
}
.quote blockquote:before {
content: "\e904";
top: 0;
left: 0;
}
.quote blockquote:after {
content: "\e903";
bottom: 1em;
right: 0;
}
.interaction {
background-color: #B2DBBF;
border-radius: 0 0 0.5em 0.5em;
display: flex;
flex-direction: row;
padding: 0.5em;
}
.interaction > * {
margin-right: 0.5em;
}
.interaction button:hover {
box-shadow: #247BA0 0em 0em 1em 0em;
color: #247BA0;
}
.interaction button {
background: white;
height: 2em;
min-width: 3em;
padding: 0;
color: #888;
}
.interaction .active button .icon {
color: #FF1654;
}
.interaction textarea {
height: 2em;
width: 23em;
float: left;
padding: 0.25em;
margin-right: 0.5em;
}
.interaction textarea:valid, .interaction textarea:focus {
height: 4em;
}
.hidden {
display: none;
}
table {
border-collapse: collapse;
margin: 1em;
}
tr {
vertical-align: top;
}
tr:nth-child(even) {
background-color: #EEE;
}
th {
font-weight: bold;
}
th, td {
padding: 1em;
text-align: left;
}
.errorlist {
list-style: none;
font-size: 0.8em;
color: #FF1654;
}
/* status css */
.time-ago {
float: right;
display: block;
text-align: right;
}
.post {
background-color: #EFEFEF;
padding-top: 1em;
padding-bottom: 1em;
}
.post h2, .compose-suggestion h2 {
position: relative;
right: 2em;
border: none;
}
.post .time-ago {
position: relative;
left: 2em;
}
.post .user-pic, .compose-suggestion .user-pic {
right: 0.25em;
}
.post h2 .subhead {
display: block;
margin-left: 2em;
}
.post .subhead .time-ago {
display: none;
}
/* status page with replies */
.comment-thread .reply h2 {
background: none;
}
.comment-thread .post {
margin-left: 4em;
border-left: 2px solid #247BA0;
}
.comment-thread .post.depth-1 {
margin-left: 0;
border: none;
}
.comment-thread .post.depth-2 {
margin-left: 1em;
}
.comment-thread .post.depth-3 {
margin-left: 2em;
}
.comment-thread .post.depth-4 {
margin-left: 3em;
}
/* pagination */
.pagination a {
text-decoration: none;
}
.pagination .next {
text-align: right;
}
/* special one-off "delete all data" banner */
#warning {
background-color: #FF1654;
text-align: center;
}

138
bookwyrm/static/icons.css Normal file
View File

@ -0,0 +1,138 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?v0wquk');
src: url('fonts/icomoon.eot?v0wquk#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?v0wquk') format('truetype'),
url('fonts/icomoon.woff?v0wquk') format('woff'),
url('fonts/icomoon.svg?v0wquk#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-arrow-right:before {
content: "\e900";
}
.icon-arrow-left:before {
content: "\e910";
}
.icon-arrow-up:before {
content: "\e911";
}
.icon-arrow-down:before {
content: "\e912";
}
.icon-x:before {
content: "\e902";
}
.icon-cancel:before {
content: "\e902";
}
.icon-close:before {
content: "\e902";
}
.icon-search:before {
content: "\e986";
}
.icon-star-empty:before {
content: "\e9d7";
}
.icon-star-half:before {
content: "\e9d8";
}
.icon-star-full:before {
content: "\e9d9";
}
.icon-heart:before {
content: "\e9da";
}
.icon-local:before {
content: "\e914";
}
.icon-home:before {
content: "\e913";
}
.icon-quote-close:before {
content: "\e903";
}
.icon-quote-open:before {
content: "\e904";
}
.icon-image:before {
content: "\e905";
}
.icon-photo:before {
content: "\e905";
}
.icon-picture-o:before {
content: "\e905";
}
.icon-pencil:before {
content: "\e906";
}
.icon-list:before {
content: "\e907";
}
.icon-unlock:before {
content: "\e908";
}
.icon-unlisted:before {
content: "\e908";
}
.icon-globe:before {
content: "\e909";
}
.icon-global:before {
content: "\e909";
}
.icon-federated:before {
content: "\e909";
}
.icon-public:before {
content: "\e909";
}
.icon-lock:before {
content: "\e90a";
}
.icon-private:before {
content: "\e90a";
}
.icon-chain-broken:before {
content: "\e90b";
}
.icon-unlink:before {
content: "\e90b";
}
.icon-chain:before {
content: "\e90c";
}
.icon-link:before {
content: "\e90c";
}
.icon-comments:before {
content: "\e90d";
}
.icon-comment:before {
content: "\e90e";
}
.icon-boost:before {
content: "\e90f";
}
.icon-bell:before {
content: "\e901";
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,65 @@
function interact(e) {
e.preventDefault();
ajaxPost(e.target);
var identifier = e.target.getAttribute('data-id');
var elements = document.getElementsByClassName(identifier);
for (var i = 0; i < elements.length; i++) {
if (elements[i].className.includes('hidden')) {
elements[i].className = elements[i].className.replace('hidden', '');
} else {
elements[i].className += ' hidden';
}
}
return true;
}
function reply(e) {
e.preventDefault();
ajaxPost(e.target);
// TODO: display comment
return true;
}
function rate_stars(e) {
e.preventDefault();
ajaxPost(e.target);
rating = e.target.rating.value;
var stars = e.target.parentElement.getElementsByClassName('icon');
for (var i = 0; i < stars.length ; i++) {
stars[i].className = rating > i ? 'icon icon-star-full' : 'icon icon-star-empty';
}
return true;
}
function tabChange(e) {
e.preventDefault();
var target = e.target.parentElement;
var identifier = target.getAttribute('data-id');
var options_class = target.getAttribute('data-category');
var options = document.getElementsByClassName(options_class);
for (var i = 0; i < options.length; i++) {
if (!options[i].className.includes('hidden')) {
options[i].className += ' hidden';
}
}
var tabs = target.parentElement.children;
for (i = 0; i < tabs.length; i++) {
if (tabs[i].getAttribute('data-id') == identifier) {
tabs[i].className += ' active';
} else {
tabs[i].className = tabs[i].className.replace('active', '');
}
}
var el = document.getElementById(identifier);
el.className = el.className.replace('hidden', '');
}
function ajaxPost(form) {
fetch(form.action, {
method : "POST",
body: new FormData(form)
});
}

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