rename main code directory
0
bookwyrm/__init__.py
Normal file
19
bookwyrm/activitypub/__init__.py
Normal 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')}
|
118
bookwyrm/activitypub/base_activity.py
Normal 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
|
67
bookwyrm/activitypub/book.py
Normal 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'
|
20
bookwyrm/activitypub/interaction.py
Normal 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'
|
50
bookwyrm/activitypub/note.py
Normal 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'
|
25
bookwyrm/activitypub/ordered_collection.py
Normal 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'
|
22
bookwyrm/activitypub/person.py
Normal 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'
|
68
bookwyrm/activitypub/verbs.py
Normal 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
@ -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
@ -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
|
2
bookwyrm/connectors/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
''' bring connectors into the namespace '''
|
||||
from .settings import CONNECTORS
|
311
bookwyrm/connectors/abstract_connector.py
Normal 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
|
108
bookwyrm/connectors/fedireads_connector.py
Normal 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
|
222
bookwyrm/connectors/openlibrary.py
Normal 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]
|
467
bookwyrm/connectors/openlibrary_languages.py
Normal 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': 'Gã',
|
||||
'/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',
|
||||
}
|
81
bookwyrm/connectors/self_connector.py
Normal 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
|
3
bookwyrm/connectors/settings.py
Normal file
@ -0,0 +1,3 @@
|
||||
''' settings book data connectors '''
|
||||
|
||||
CONNECTORS = ['openlibrary', 'self_connector', 'fedireads_connector']
|
152
bookwyrm/forms.py
Normal 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')])
|
||||
}
|
54
bookwyrm/goodreads_import.py
Normal 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
@ -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)
|
213
bookwyrm/migrations/0001_initial.py
Normal 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',),
|
||||
),
|
||||
]
|
38
bookwyrm/migrations/0002_auto_20200219_0816.py
Normal 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'),
|
||||
),
|
||||
]
|
93
bookwyrm/migrations/0003_auto_20200221_0131.py
Normal 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),
|
||||
),
|
||||
]
|
29
bookwyrm/migrations/0004_tag.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
32
bookwyrm/migrations/0005_auto_20200221_1645.py
Normal 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),
|
||||
]
|
18
bookwyrm/migrations/0006_auto_20200221_1702.py
Normal 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),
|
||||
),
|
||||
]
|
17
bookwyrm/migrations/0007_auto_20200223_0902.py
Normal 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'),
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0008_auto_20200224_1504.py
Normal 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),
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0009_status_published_date.py
Normal 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),
|
||||
),
|
||||
]
|
190
bookwyrm/migrations/0010_auto_20200307_0655.py
Normal 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'),
|
||||
),
|
||||
]
|
32
bookwyrm/migrations/0011_notification.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0012_auto_20200308_1625.py
Normal 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),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0013_user_manually_approves_followers.py
Normal 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),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0014_status_remote_id.py
Normal 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),
|
||||
),
|
||||
]
|
115
bookwyrm/migrations/0015_auto_20200311_1212.py
Normal 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'),
|
||||
),
|
||||
]
|
22
bookwyrm/migrations/0016_auto_20200313_1337.py
Normal 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'),
|
||||
),
|
||||
]
|
26
bookwyrm/migrations/0017_auto_20200314_2152.py
Normal 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'),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0018_favorite_remote_id.py
Normal 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),
|
||||
),
|
||||
]
|
26
bookwyrm/migrations/0019_comment.py
Normal 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',),
|
||||
),
|
||||
]
|
58
bookwyrm/migrations/0020_auto_20200327_2335.py
Normal 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'),
|
||||
),
|
||||
]
|
44
bookwyrm/migrations/0021_auto_20200328_0428.py
Normal 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),
|
||||
),
|
||||
]
|
27
bookwyrm/migrations/0022_auto_20200328_2001.py
Normal 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),
|
||||
),
|
||||
]
|
114
bookwyrm/migrations/0023_auto_20200328_2203.py
Normal 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),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
24
bookwyrm/migrations/0025_auto_20200330_0037.py
Normal 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),
|
||||
),
|
||||
]
|
42
bookwyrm/migrations/0026_auto_20200330_1456.py
Normal 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'),
|
||||
),
|
||||
]
|
82
bookwyrm/migrations/0027_auto_20200330_2232.py
Normal 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'),
|
||||
),
|
||||
]
|
23
bookwyrm/migrations/0028_auto_20200401_1824.py
Normal 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)]),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0029_auto_20200403_1835.py
Normal 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),
|
||||
),
|
||||
]
|
26
bookwyrm/migrations/0030_quotation.py
Normal 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',),
|
||||
),
|
||||
]
|
31
bookwyrm/migrations/0031_readthrough.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
60
bookwyrm/migrations/0032_auto_20200421_1347.py
Normal 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'),
|
||||
),
|
||||
]
|
43
bookwyrm/migrations/0033_auto_20200422_1249.py
Normal 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'),
|
||||
),
|
||||
]
|
19
bookwyrm/migrations/0034_importjob_import_status.py
Normal 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'),
|
||||
),
|
||||
]
|
33
bookwyrm/migrations/0035_auto_20200429_1708.py
Normal 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),
|
||||
),
|
||||
]
|
39
bookwyrm/migrations/0036_auto_20200503_2007.py
Normal 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),
|
||||
),
|
||||
]
|
41
bookwyrm/migrations/0037_auto_20200504_0154.py
Normal 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),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0038_author_remote_id.py
Normal 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),
|
||||
),
|
||||
]
|
21
bookwyrm/migrations/0039_auto_20200510_2342.py
Normal 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',
|
||||
),
|
||||
]
|
77
bookwyrm/migrations/0040_auto_20200513_0153.py
Normal 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),
|
||||
),
|
||||
]
|
18
bookwyrm/migrations/0041_user_remote_id.py
Normal 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),
|
||||
),
|
||||
]
|
21
bookwyrm/migrations/0042_auto_20200524_0346.py
Normal 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',
|
||||
),
|
||||
]
|
23
bookwyrm/migrations/0042_sitesettings.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
24
bookwyrm/migrations/0043_siteinvite.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
21
bookwyrm/migrations/0044_siteinvite_user.py
Normal 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,
|
||||
),
|
||||
]
|
14
bookwyrm/migrations/0045_merge_20200810_2010.py
Normal 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 = [
|
||||
]
|
0
bookwyrm/migrations/__init__.py
Normal file
20
bookwyrm/models/__init__.py
Normal 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')}
|
219
bookwyrm/models/base_model.py
Normal 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
@ -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
|
40
bookwyrm/models/connector.py
Normal 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'
|
||||
)
|
||||
]
|
15
bookwyrm/models/federated_server.py
Normal 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
|
121
bookwyrm/models/import_job.py
Normal 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'])
|
89
bookwyrm/models/relationship.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
16
bookwyrm/routine_book_tasks.py
Normal 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
@ -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
@ -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
@ -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()
|
BIN
bookwyrm/static/fonts/icomoon.eot
Normal file
36
bookwyrm/static/fonts/icomoon.svg
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
bookwyrm/static/fonts/icomoon.ttf
Normal file
BIN
bookwyrm/static/fonts/icomoon.woff
Normal file
822
bookwyrm/static/format.css
Normal 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
@ -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";
|
||||
}
|
BIN
bookwyrm/static/images/default_avi.jpg
Normal file
After Width: | Height: | Size: 6.6 KiB |
BIN
bookwyrm/static/images/logo-small.png
Normal file
After Width: | Height: | Size: 7.9 KiB |
BIN
bookwyrm/static/images/logo.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
bookwyrm/static/images/med.jpg
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
bookwyrm/static/images/no_cover.jpg
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
bookwyrm/static/images/profile.jpg
Normal file
After Width: | Height: | Size: 34 KiB |
BIN
bookwyrm/static/images/small.jpg
Normal file
After Width: | Height: | Size: 1.1 KiB |
65
bookwyrm/static/js/shared.js
Normal 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)
|
||||
});
|
||||
}
|