Merge branch 'main' into production

This commit is contained in:
Mouse Reeve 2021-03-08 10:02:46 -08:00
commit 6c7fcb0dd1
208 changed files with 10295 additions and 8610 deletions

13
.github/workflows/black.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: psf/black@stable
with:
args: ". --check -l 80 -S"

View file

@ -1,4 +1,4 @@
''' bring activitypub functions into the namespace '''
""" bring activitypub functions into the namespace """
import inspect
import sys
@ -21,9 +21,9 @@ from .verbs import Announce, Like
# 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')}
activity_objects = {c[0]: c[1] for c in cls_members if hasattr(c[1], "to_model")}
def parse(activity_json):
''' figure out what activity this is and parse it '''
""" figure out what activity this is and parse it """
return naive_parse(activity_objects, activity_json)

View file

@ -1,4 +1,4 @@
''' basics for an activitypub serializer '''
""" basics for an activitypub serializer """
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
@ -8,46 +8,52 @@ from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
class ActivitySerializerError(ValueError):
''' routine problems serializing activitypub json '''
""" routine problems serializing activitypub json """
class ActivityEncoder(JSONEncoder):
''' used to convert an Activity object into json '''
""" used to convert an Activity object into json """
def default(self, o):
return o.__dict__
@dataclass
class Link:
''' for tagging a book in a status '''
""" for tagging a book in a status """
href: str
name: str
type: str = 'Link'
type: str = "Link"
@dataclass
class Mention(Link):
''' a subtype of Link for mentioning an actor '''
type: str = 'Mention'
""" a subtype of Link for mentioning an actor """
type: str = "Mention"
@dataclass
class Signature:
''' public key block '''
""" public key block """
creator: str
created: str
signatureValue: str
type: str = 'RsaSignature2017'
type: str = "RsaSignature2017"
def naive_parse(activity_objects, activity_json, serializer=None):
''' this navigates circular import issues '''
""" this navigates circular import issues """
if not serializer:
if activity_json.get('publicKeyPem'):
if activity_json.get("publicKeyPem"):
# ugh
activity_json['type'] = 'PublicKey'
activity_json["type"] = "PublicKey"
try:
activity_type = activity_json['type']
activity_type = activity_json["type"]
serializer = activity_objects[activity_type]
except KeyError as e:
raise ActivitySerializerError(e)
@ -57,14 +63,15 @@ def naive_parse(activity_objects, activity_json, serializer=None):
@dataclass(init=False)
class ActivityObject:
''' actor activitypub json '''
""" actor activitypub json """
id: str
type: str
def __init__(self, activity_objects=None, **kwargs):
''' this lets you pass in an object with fields that aren't in the
"""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 '''
has a default value"""
for field in fields(self):
try:
value = kwargs[field.name]
@ -75,7 +82,7 @@ class ActivityObject:
except TypeError:
is_subclass = False
# serialize a model obj
if hasattr(value, 'to_activity'):
if hasattr(value, "to_activity"):
value = value.to_activity()
# parse a dict into the appropriate activity
elif is_subclass and isinstance(value, dict):
@ -83,25 +90,27 @@ class ActivityObject:
value = naive_parse(activity_objects, value)
else:
value = naive_parse(
activity_objects, value, serializer=field.type)
activity_objects, value, serializer=field.type
)
except KeyError:
if field.default == MISSING and \
field.default_factory == MISSING:
raise ActivitySerializerError(\
'Missing required field: %s' % field.name)
if field.default == MISSING and field.default_factory == MISSING:
raise ActivitySerializerError(
"Missing required field: %s" % field.name
)
value = field.default
setattr(self, field.name, value)
def to_model(self, model=None, instance=None, allow_create=True, save=True):
''' convert from an activity to a model instance '''
""" convert from an activity to a model instance """
model = model or get_model_from_type(self.type)
# only reject statuses if we're potentially creating them
if allow_create and \
hasattr(model, 'ignore_activity') and \
model.ignore_activity(self):
if (
allow_create
and hasattr(model, "ignore_activity")
and model.ignore_activity(self)
):
raise ActivitySerializerError()
# check for an existing instance
@ -142,8 +151,10 @@ class ActivityObject:
field.set_field_from_activity(instance, self)
# reversed relationships in the models
for (model_field_name, activity_field_name) in \
instance.deserialize_reverse_fields:
for (
model_field_name,
activity_field_name,
) in instance.deserialize_reverse_fields:
# attachments on Status, for example
values = getattr(self, activity_field_name)
if values is None or values is MISSING:
@ -161,13 +172,12 @@ class ActivityObject:
instance.__class__.__name__,
related_field_name,
instance.remote_id,
item
item,
)
return instance
def serialize(self):
''' convert to dictionary with context attr '''
""" convert to dictionary with context attr """
data = self.__dict__.copy()
# recursively serialize
for (k, v) in data.items():
@ -176,22 +186,19 @@ class ActivityObject:
data[k] = v.serialize()
except TypeError:
pass
data = {k:v for (k, v) in data.items() if v is not None}
data['@context'] = 'https://www.w3.org/ns/activitystreams'
data = {k: v for (k, v) in data.items() if v is not None}
data["@context"] = "https://www.w3.org/ns/activitystreams"
return data
@app.task
@transaction.atomic
def set_related_field(
model_name, origin_model_name, related_field_name,
related_remote_id, data):
''' load reverse related fields (editions, attachments) without blocking '''
model = apps.get_model('bookwyrm.%s' % model_name, require_ready=True)
origin_model = apps.get_model(
'bookwyrm.%s' % origin_model_name,
require_ready=True
)
model_name, origin_model_name, related_field_name, related_remote_id, data
):
""" load reverse related fields (editions, attachments) without blocking """
model = apps.get_model("bookwyrm.%s" % model_name, require_ready=True)
origin_model = apps.get_model("bookwyrm.%s" % origin_model_name, require_ready=True)
with transaction.atomic():
if isinstance(data, str):
@ -205,43 +212,45 @@ def set_related_field(
# this must exist because it's the object that triggered this function
instance = origin_model.find_existing_by_remote_id(related_remote_id)
if not instance:
raise ValueError(
'Invalid related remote id: %s' % related_remote_id)
raise ValueError("Invalid related remote id: %s" % related_remote_id)
# set the origin's remote id on the activity so it will be there when
# the model instance is created
# edition.parentWork = instance, for example
model_field = getattr(model, related_field_name)
if hasattr(model_field, 'activitypub_field'):
if hasattr(model_field, "activitypub_field"):
setattr(
activity,
getattr(model_field, 'activitypub_field'),
instance.remote_id
activity, getattr(model_field, "activitypub_field"), instance.remote_id
)
item = activity.to_model()
# if the related field isn't serialized (attachments on Status), then
# we have to set it post-creation
if not hasattr(model_field, 'activitypub_field'):
if not hasattr(model_field, "activitypub_field"):
setattr(item, related_field_name, instance)
item.save()
def get_model_from_type(activity_type):
''' given the activity, what type of model '''
""" given the activity, what type of model """
models = apps.get_models()
model = [m for m in models if hasattr(m, 'activity_serializer') and \
hasattr(m.activity_serializer, 'type') and \
m.activity_serializer.type == activity_type]
model = [
m
for m in models
if hasattr(m, "activity_serializer")
and hasattr(m.activity_serializer, "type")
and m.activity_serializer.type == activity_type
]
if not model:
raise ActivitySerializerError(
'No model found for activity type "%s"' % activity_type)
'No model found for activity type "%s"' % activity_type
)
return model[0]
def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
''' take a remote_id and return an instance, creating if necessary '''
if model:# a bonus check we can do if we already know the model
""" take a remote_id and return an instance, creating if necessary """
if model: # a bonus check we can do if we already know the model
result = model.find_existing_by_remote_id(remote_id)
if result and not refresh:
return result
@ -251,11 +260,12 @@ def resolve_remote_id(remote_id, model=None, refresh=False, save=True):
data = get_data(remote_id)
except (ConnectorException, ConnectionError):
raise ActivitySerializerError(
'Could not connect to host for remote_id in %s model: %s' % \
(model.__name__, remote_id))
"Could not connect to host for remote_id in %s model: %s"
% (model.__name__, remote_id)
)
# determine the model implicitly, if not provided
if not model:
model = get_model_from_type(data.get('type'))
model = get_model_from_type(data.get("type"))
# check for existing items with shared unique identifiers
result = model.find_existing(data)

View file

@ -1,70 +1,75 @@
''' book and author data '''
""" book and author data """
from dataclasses import dataclass, field
from typing import List
from .base_activity import ActivityObject
from .image import Image
@dataclass(init=False)
class Book(ActivityObject):
''' serializes an edition or work, abstract '''
""" serializes an edition or work, abstract """
title: str
sortTitle: str = ''
subtitle: str = ''
description: str = ''
sortTitle: str = ""
subtitle: str = ""
description: str = ""
languages: List[str] = field(default_factory=lambda: [])
series: str = ''
seriesNumber: str = ''
series: str = ""
seriesNumber: str = ""
subjects: List[str] = field(default_factory=lambda: [])
subjectPlaces: List[str] = field(default_factory=lambda: [])
authors: List[str] = field(default_factory=lambda: [])
firstPublishedDate: str = ''
publishedDate: str = ''
firstPublishedDate: str = ""
publishedDate: str = ""
openlibraryKey: str = ''
librarythingKey: str = ''
goodreadsKey: str = ''
openlibraryKey: str = ""
librarythingKey: str = ""
goodreadsKey: str = ""
cover: Image = None
type: str = 'Book'
type: str = "Book"
@dataclass(init=False)
class Edition(Book):
''' Edition instance of a book object '''
""" Edition instance of a book object """
work: str
isbn10: str = ''
isbn13: str = ''
oclcNumber: str = ''
asin: str = ''
isbn10: str = ""
isbn13: str = ""
oclcNumber: str = ""
asin: str = ""
pages: int = None
physicalFormat: str = ''
physicalFormat: str = ""
publishers: List[str] = field(default_factory=lambda: [])
editionRank: int = 0
type: str = 'Edition'
type: str = "Edition"
@dataclass(init=False)
class Work(Book):
''' work instance of a book object '''
lccn: str = ''
defaultEdition: str = ''
""" work instance of a book object """
lccn: str = ""
defaultEdition: str = ""
editions: List[str] = field(default_factory=lambda: [])
type: str = 'Work'
type: str = "Work"
@dataclass(init=False)
class Author(ActivityObject):
''' author of a book '''
""" author of a book """
name: str
born: str = None
died: str = None
aliases: List[str] = field(default_factory=lambda: [])
bio: str = ''
openlibraryKey: str = ''
librarythingKey: str = ''
goodreadsKey: str = ''
wikipediaLink: str = ''
type: str = 'Author'
bio: str = ""
openlibraryKey: str = ""
librarythingKey: str = ""
goodreadsKey: str = ""
wikipediaLink: str = ""
type: str = "Author"

View file

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

View file

@ -1,4 +1,4 @@
''' note serializer and children thereof '''
""" note serializer and children thereof """
from dataclasses import dataclass, field
from typing import Dict, List
from django.apps import apps
@ -6,64 +6,72 @@ from django.apps import apps
from .base_activity import ActivityObject, Link
from .image import Image
@dataclass(init=False)
class Tombstone(ActivityObject):
''' the placeholder for a deleted status '''
type: str = 'Tombstone'
""" the placeholder for a deleted status """
type: str = "Tombstone"
def to_model(self, *args, **kwargs):
''' this should never really get serialized, just searched for '''
model = apps.get_model('bookwyrm.Status')
""" this should never really get serialized, just searched for """
model = apps.get_model("bookwyrm.Status")
return model.find_existing_by_remote_id(self.id)
@dataclass(init=False)
class Note(ActivityObject):
''' Note activity '''
""" Note activity """
published: str
attributedTo: str
content: str = ''
content: str = ""
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
replies: Dict = field(default_factory=lambda: {})
inReplyTo: str = ''
summary: str = ''
inReplyTo: str = ""
summary: str = ""
tag: List[Link] = field(default_factory=lambda: [])
attachment: List[Image] = field(default_factory=lambda: [])
sensitive: bool = False
type: str = 'Note'
type: str = "Note"
@dataclass(init=False)
class Article(Note):
''' what's an article except a note with more fields '''
""" what's an article except a note with more fields """
name: str
type: str = 'Article'
type: str = "Article"
@dataclass(init=False)
class GeneratedNote(Note):
''' just a re-typed note '''
type: str = 'GeneratedNote'
""" just a re-typed note """
type: str = "GeneratedNote"
@dataclass(init=False)
class Comment(Note):
''' like a note but with a book '''
""" like a note but with a book """
inReplyToBook: str
type: str = 'Comment'
type: str = "Comment"
@dataclass(init=False)
class Review(Comment):
''' a full book review '''
""" a full book review """
name: str = None
rating: int = None
type: str = 'Review'
type: str = "Review"
@dataclass(init=False)
class Quotation(Comment):
''' a quote and commentary on a book '''
""" a quote and commentary on a book """
quote: str
type: str = 'Quotation'
type: str = "Quotation"

View file

@ -1,4 +1,4 @@
''' defines activitypub collections (lists) '''
""" defines activitypub collections (lists) """
from dataclasses import dataclass, field
from typing import List
@ -7,38 +7,46 @@ from .base_activity import ActivityObject
@dataclass(init=False)
class OrderedCollection(ActivityObject):
''' structure of an ordered collection activity '''
""" structure of an ordered collection activity """
totalItems: int
first: str
last: str = None
name: str = None
owner: str = None
type: str = 'OrderedCollection'
type: str = "OrderedCollection"
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
''' an ordered collection with privacy settings '''
""" an ordered collection with privacy settings """
to: List[str] = field(default_factory=lambda: [])
cc: List[str] = field(default_factory=lambda: [])
@dataclass(init=False)
class Shelf(OrderedCollectionPrivate):
''' structure of an ordered collection activity '''
type: str = 'Shelf'
""" structure of an ordered collection activity """
type: str = "Shelf"
@dataclass(init=False)
class BookList(OrderedCollectionPrivate):
''' structure of an ordered collection activity '''
""" structure of an ordered collection activity """
summary: str = None
curation: str = 'closed'
type: str = 'BookList'
curation: str = "closed"
type: str = "BookList"
@dataclass(init=False)
class OrderedCollectionPage(ActivityObject):
''' structure of an ordered collection activity '''
""" structure of an ordered collection activity """
partOf: str
orderedItems: List
next: str = None
prev: str = None
type: str = 'OrderedCollectionPage'
type: str = "OrderedCollectionPage"

View file

@ -1,4 +1,4 @@
''' actor serializer '''
""" actor serializer """
from dataclasses import dataclass, field
from typing import Dict
@ -8,15 +8,17 @@ from .image import Image
@dataclass(init=False)
class PublicKey(ActivityObject):
''' public key block '''
""" public key block """
owner: str
publicKeyPem: str
type: str = 'PublicKey'
type: str = "PublicKey"
@dataclass(init=False)
class Person(ActivityObject):
''' actor activitypub json '''
""" actor activitypub json """
preferredUsername: str
inbox: str
outbox: str
@ -29,4 +31,4 @@ class Person(ActivityObject):
bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False
discoverable: str = True
type: str = 'Person'
type: str = "Person"

View file

@ -2,6 +2,7 @@ from django.http import JsonResponse
from .base_activity import ActivityEncoder
class ActivitypubResponse(JsonResponse):
"""
A class to be used in any place that's serializing responses for
@ -9,10 +10,17 @@ class ActivitypubResponse(JsonResponse):
configures some stuff beforehand. Made to be a drop-in replacement of
JsonResponse.
"""
def __init__(self, data, encoder=ActivityEncoder, safe=False,
json_dumps_params=None, **kwargs):
if 'content_type' not in kwargs:
kwargs['content_type'] = 'application/activity+json'
def __init__(
self,
data,
encoder=ActivityEncoder,
safe=False,
json_dumps_params=None,
**kwargs
):
if "content_type" not in kwargs:
kwargs["content_type"] = "application/activity+json"
super().__init__(data, encoder, safe, json_dumps_params, **kwargs)

View file

@ -1,4 +1,4 @@
''' undo wrapper activity '''
""" undo wrapper activity """
from dataclasses import dataclass
from typing import List
from django.apps import apps
@ -9,160 +9,173 @@ from .book import Edition
@dataclass(init=False)
class Verb(ActivityObject):
''' generic fields for activities - maybe an unecessary level of
abstraction but w/e '''
"""generic fields for activities - maybe an unecessary level of
abstraction but w/e"""
actor: str
object: ActivityObject
def action(self):
''' usually we just want to save, this can be overridden as needed '''
""" usually we just want to save, this can be overridden as needed """
self.object.to_model()
@dataclass(init=False)
class Create(Verb):
''' Create activity '''
""" Create activity """
to: List
cc: List
signature: Signature = None
type: str = 'Create'
type: str = "Create"
@dataclass(init=False)
class Delete(Verb):
''' Create activity '''
""" Create activity """
to: List
cc: List
type: str = 'Delete'
type: str = "Delete"
def action(self):
''' find and delete the activity object '''
""" find and delete the activity object """
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Update(Verb):
''' Update activity '''
""" Update activity """
to: List
type: str = 'Update'
type: str = "Update"
def action(self):
''' update a model instance from the dataclass '''
""" update a model instance from the dataclass """
self.object.to_model(allow_create=False)
@dataclass(init=False)
class Undo(Verb):
''' Undo an activity '''
type: str = 'Undo'
""" Undo an activity """
type: str = "Undo"
def action(self):
''' find and remove the activity object '''
""" find and remove the activity object """
# this is so hacky but it does make it work....
# (because you Reject a request and Undo a follow
model = None
if self.object.type == 'Follow':
model = apps.get_model('bookwyrm.UserFollows')
if self.object.type == "Follow":
model = apps.get_model("bookwyrm.UserFollows")
obj = self.object.to_model(model=model, save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Follow(Verb):
''' Follow activity '''
""" Follow activity """
object: str
type: str = 'Follow'
type: str = "Follow"
def action(self):
''' relationship save '''
""" relationship save """
self.to_model()
@dataclass(init=False)
class Block(Verb):
''' Block activity '''
""" Block activity """
object: str
type: str = 'Block'
type: str = "Block"
def action(self):
''' relationship save '''
""" relationship save """
self.to_model()
@dataclass(init=False)
class Accept(Verb):
''' Accept activity '''
""" Accept activity """
object: Follow
type: str = 'Accept'
type: str = "Accept"
def action(self):
''' find and remove the activity object '''
""" find and remove the activity object """
obj = self.object.to_model(save=False, allow_create=False)
obj.accept()
@dataclass(init=False)
class Reject(Verb):
''' Reject activity '''
""" Reject activity """
object: Follow
type: str = 'Reject'
type: str = "Reject"
def action(self):
''' find and remove the activity object '''
""" find and remove the activity object """
obj = self.object.to_model(save=False, allow_create=False)
obj.reject()
@dataclass(init=False)
class Add(Verb):
'''Add activity '''
"""Add activity """
target: str
object: Edition
type: str = 'Add'
type: str = "Add"
notes: str = None
order: int = 0
approved: bool = True
def action(self):
''' add obj to collection '''
""" add obj to collection """
target = resolve_remote_id(self.target, refresh=False)
# we want to related field that isn't the book, this is janky af sorry
model = [t for t in type(target)._meta.related_objects \
if t.name != 'edition'][0].related_model
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
0
].related_model
self.to_model(model=model)
@dataclass(init=False)
class Remove(Verb):
'''Remove activity '''
"""Remove activity """
target: ActivityObject
type: str = 'Remove'
type: str = "Remove"
def action(self):
''' find and remove the activity object '''
""" find and remove the activity object """
obj = self.object.to_model(save=False, allow_create=False)
obj.delete()
@dataclass(init=False)
class Like(Verb):
''' a user faving an object '''
""" a user faving an object """
object: str
type: str = 'Like'
type: str = "Like"
def action(self):
''' like '''
""" like """
self.to_model()
@dataclass(init=False)
class Announce(Verb):
''' boosting a status '''
""" boosting a status """
object: str
type: str = 'Announce'
type: str = "Announce"
def action(self):
''' boost '''
""" boost """
self.to_model()

View file

@ -1,4 +1,4 @@
''' models that will show up in django admin for superuser '''
""" models that will show up in django admin for superuser """
from django.contrib import admin
from bookwyrm import models

View file

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

View file

@ -1,4 +1,4 @@
''' functionality outline for a book data connector '''
""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass
import logging
@ -13,8 +13,11 @@ from .connector_manager import load_more_data, ConnectorException
logger = logging.getLogger(__name__)
class AbstractMinimalConnector(ABC):
''' just the bare bones, for other bookwyrm instances '''
""" just the bare bones, for other bookwyrm instances """
def __init__(self, identifier):
# load connector settings
info = models.Connector.objects.get(identifier=identifier)
@ -22,31 +25,31 @@ class AbstractMinimalConnector(ABC):
# the things in the connector model to copy over
self_fields = [
'base_url',
'books_url',
'covers_url',
'search_url',
'isbn_search_url',
'max_query_count',
'name',
'identifier',
'local'
"base_url",
"books_url",
"covers_url",
"search_url",
"isbn_search_url",
"max_query_count",
"name",
"identifier",
"local",
]
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None):
''' free text search '''
""" free text search """
params = {}
if min_confidence:
params['min_confidence'] = min_confidence
params["min_confidence"] = min_confidence
resp = requests.get(
'%s%s' % (self.search_url, query),
"%s%s" % (self.search_url, query),
params=params,
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
)
if not resp.ok:
@ -55,7 +58,7 @@ class AbstractMinimalConnector(ABC):
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException('Unable to parse json response', e)
raise ConnectorException("Unable to parse json response", e)
results = []
for doc in self.parse_search_data(data)[:10]:
@ -63,14 +66,14 @@ class AbstractMinimalConnector(ABC):
return results
def isbn_search(self, query):
''' isbn search '''
""" isbn search """
params = {}
resp = requests.get(
'%s%s' % (self.isbn_search_url, query),
"%s%s" % (self.isbn_search_url, query),
params=params,
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
)
if not resp.ok:
@ -79,7 +82,7 @@ class AbstractMinimalConnector(ABC):
data = resp.json()
except ValueError as e:
logger.exception(e)
raise ConnectorException('Unable to parse json response', e)
raise ConnectorException("Unable to parse json response", e)
results = []
for doc in self.parse_isbn_search_data(data):
@ -88,49 +91,49 @@ class AbstractMinimalConnector(ABC):
@abstractmethod
def get_or_create_book(self, remote_id):
''' pull up a book record by whatever means possible '''
""" pull up a book record by whatever means possible """
@abstractmethod
def parse_search_data(self, data):
''' turn the result json from a search into a list '''
""" turn the result json from a search into a list """
@abstractmethod
def format_search_result(self, search_result):
''' create a SearchResult obj from json '''
""" create a SearchResult obj from json """
@abstractmethod
def parse_isbn_search_data(self, data):
''' turn the result json from a search into a list '''
""" turn the result json from a search into a list """
@abstractmethod
def format_isbn_search_result(self, search_result):
''' create a SearchResult obj from json '''
""" create a SearchResult obj from json """
class AbstractConnector(AbstractMinimalConnector):
''' generic book data connector '''
""" generic book data connector """
def __init__(self, identifier):
super().__init__(identifier)
# fields we want to look for in book data to copy over
# title we handle separately.
self.book_mappings = []
def is_available(self):
''' check if you're allowed to use this connector '''
""" check if you're allowed to use this connector """
if self.max_query_count is not None:
if self.connector.query_count >= self.max_query_count:
return False
return True
def get_or_create_book(self, remote_id):
''' translate arbitrary json into an Activitypub dataclass '''
""" translate arbitrary json into an Activitypub dataclass """
# first, check if we have the origin_id saved
existing = models.Edition.find_existing_by_remote_id(remote_id) or \
models.Work.find_existing_by_remote_id(remote_id)
existing = models.Edition.find_existing_by_remote_id(
remote_id
) or models.Work.find_existing_by_remote_id(remote_id)
if existing:
if hasattr(existing, 'get_default_editon'):
if hasattr(existing, "get_default_editon"):
return existing.get_default_editon()
return existing
@ -154,7 +157,7 @@ class AbstractConnector(AbstractMinimalConnector):
edition_data = data
if not work_data or not edition_data:
raise ConnectorException('Unable to load book data: %s' % remote_id)
raise ConnectorException("Unable to load book data: %s" % remote_id)
with transaction.atomic():
# create activitypub object
@ -168,11 +171,10 @@ class AbstractConnector(AbstractMinimalConnector):
load_more_data.delay(self.connector.id, work.id)
return edition
def create_edition_from_data(self, work, edition_data):
''' if we already have the work, we're ready '''
""" if we already have the work, we're ready """
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data['work'] = work.remote_id
mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
edition = edition_activity.to_model(model=models.Edition)
edition.connector = self.connector
@ -189,9 +191,8 @@ class AbstractConnector(AbstractMinimalConnector):
return edition
def get_or_create_author(self, remote_id):
''' load that author '''
""" load that author """
existing = models.Author.find_existing_by_remote_id(remote_id)
if existing:
return existing
@ -203,31 +204,30 @@ class AbstractConnector(AbstractMinimalConnector):
# this will dedupe
return activity.to_model(model=models.Author)
@abstractmethod
def is_work_data(self, data):
''' differentiate works and editions '''
""" differentiate works and editions """
@abstractmethod
def get_edition_from_work_data(self, data):
''' every work needs at least one edition '''
""" every work needs at least one edition """
@abstractmethod
def get_work_from_edition_data(self, data):
''' every edition needs a work '''
""" every edition needs a work """
@abstractmethod
def get_authors_from_data(self, data):
''' load author data '''
""" load author data """
@abstractmethod
def expand_book_data(self, book):
''' get more info on a book '''
""" get more info on a book """
def dict_from_mappings(data, mappings):
''' create a dict in Activitypub format, using mappings supplies by
the subclass '''
"""create a dict in Activitypub format, using mappings supplies by
the subclass"""
result = {}
for mapping in mappings:
result[mapping.local_field] = mapping.get_value(data)
@ -235,13 +235,13 @@ def dict_from_mappings(data, mappings):
def get_data(url):
''' wrapper for request.get '''
""" wrapper for request.get """
try:
resp = requests.get(
url,
headers={
'Accept': 'application/json; charset=utf-8',
'User-Agent': settings.USER_AGENT,
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
)
except (RequestError, SSLError) as e:
@ -260,12 +260,12 @@ def get_data(url):
def get_image(url):
''' wrapper for requesting an image '''
""" wrapper for requesting an image """
try:
resp = requests.get(
url,
headers={
'User-Agent': settings.USER_AGENT,
"User-Agent": settings.USER_AGENT,
},
)
except (RequestError, SSLError) as e:
@ -278,7 +278,8 @@ def get_image(url):
@dataclass
class SearchResult:
''' standardized search result object '''
""" standardized search result object """
title: str
key: str
author: str
@ -288,17 +289,19 @@ class SearchResult:
def __repr__(self):
return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author)
self.key, self.title, self.author
)
def json(self):
''' serialize a connector for json response '''
""" serialize a connector for json response """
serialized = asdict(self)
del serialized['connector']
del serialized["connector"]
return serialized
class Mapping:
''' associate a local database field with a field in an external dataset '''
""" associate a local database field with a field in an external dataset """
def __init__(self, local_field, remote_field=None, formatter=None):
noop = lambda x: x
@ -307,11 +310,11 @@ class Mapping:
self.formatter = formatter or noop
def get_value(self, data):
''' pull a field from incoming json and return the formatted version '''
""" pull a field from incoming json and return the formatted version """
value = data.get(self.remote_field)
if not value:
return None
try:
return self.formatter(value)
except:# pylint: disable=bare-except
except: # pylint: disable=bare-except
return None

View file

@ -1,10 +1,10 @@
''' using another bookwyrm instance as a source of book data '''
""" using another bookwyrm instance as a source of book data """
from bookwyrm import activitypub, models
from .abstract_connector import AbstractMinimalConnector, SearchResult
class Connector(AbstractMinimalConnector):
''' this is basically just for search '''
""" this is basically just for search """
def get_or_create_book(self, remote_id):
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)
@ -17,13 +17,12 @@ class Connector(AbstractMinimalConnector):
return data
def format_search_result(self, search_result):
search_result['connector'] = self
search_result["connector"] = self
return SearchResult(**search_result)
def parse_isbn_search_data(self, data):
return data
def format_isbn_search_result(self, search_result):
search_result['connector'] = self
search_result["connector"] = self
return SearchResult(**search_result)

View file

@ -1,4 +1,4 @@
''' interface with whatever connectors the app has '''
""" interface with whatever connectors the app has """
import importlib
import re
from urllib.parse import urlparse
@ -10,24 +10,24 @@ from bookwyrm.tasks import app
class ConnectorException(HTTPError):
''' when the connector can't do what was asked '''
""" when the connector can't do what was asked """
def search(query, min_confidence=0.1):
''' find books based on arbitary keywords '''
""" find books based on arbitary keywords """
results = []
# Have we got a ISBN ?
isbn = re.sub('[\W_]', '', query)
isbn = re.sub("[\W_]", "", query)
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year)
dedup_slug = lambda r: "%s/%s/%s" % (r.title, r.author, r.year)
result_index = set()
for connector in get_connectors():
result_set = None
if maybe_isbn:
# Search on ISBN
if not connector.isbn_search_url or connector.isbn_search_url == '':
if not connector.isbn_search_url or connector.isbn_search_url == "":
result_set = []
else:
try:
@ -42,32 +42,33 @@ def search(query, min_confidence=0.1):
except (HTTPError, ConnectorException):
continue
result_set = [r for r in result_set \
if dedup_slug(r) not in result_index]
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,
})
results.append(
{
"connector": connector,
"results": result_set,
}
)
return results
def local_search(query, min_confidence=0.1, raw=False):
''' only look at local search results '''
""" only look at local search results """
connector = load_connector(models.Connector.objects.get(local=True))
return connector.search(query, min_confidence=min_confidence, raw=raw)
def isbn_local_search(query, raw=False):
''' only look at local search results '''
""" only look at local search results """
connector = load_connector(models.Connector.objects.get(local=True))
return connector.isbn_search(query, raw=raw)
def first_search_result(query, min_confidence=0.1):
''' search until you find a result that fits '''
""" search until you find a result that fits """
for connector in get_connectors():
result = connector.search(query, min_confidence=min_confidence)
if result:
@ -76,29 +77,29 @@ def first_search_result(query, min_confidence=0.1):
def get_connectors():
''' load all connectors '''
for info in models.Connector.objects.order_by('priority').all():
""" load all connectors """
for info in models.Connector.objects.order_by("priority").all():
yield load_connector(info)
def get_or_create_connector(remote_id):
''' get the connector related to the author's server '''
""" get the connector related to the author's server """
url = urlparse(remote_id)
identifier = url.netloc
if not identifier:
raise ValueError('Invalid remote id')
raise ValueError("Invalid remote id")
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file='bookwyrm_connector',
base_url='https://%s' % identifier,
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
priority=2
connector_file="bookwyrm_connector",
base_url="https://%s" % identifier,
books_url="https://%s/book" % identifier,
covers_url="https://%s/images/covers" % identifier,
search_url="https://%s/search?q=" % identifier,
priority=2,
)
return load_connector(connector_info)
@ -106,7 +107,7 @@ def get_or_create_connector(remote_id):
@app.task
def load_more_data(connector_id, book_id):
''' background the work of getting all 10,000 editions of LoTR '''
""" background the work of getting all 10,000 editions of LoTR """
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
book = models.Book.objects.select_subclasses().get(id=book_id)
@ -114,8 +115,8 @@ def load_more_data(connector_id, book_id):
def load_connector(connector_info):
''' instantiate the connector class '''
""" instantiate the connector class """
connector = importlib.import_module(
'bookwyrm.connectors.%s' % connector_info.connector_file
"bookwyrm.connectors.%s" % connector_info.connector_file
)
return connector.Connector(connector_info.identifier)

View file

@ -1,4 +1,4 @@
''' openlibrary data connector '''
""" openlibrary data connector """
import re
from bookwyrm import models
@ -9,148 +9,134 @@ from .openlibrary_languages import languages
class Connector(AbstractConnector):
''' instantiate a connector for OL '''
""" instantiate a connector for OL """
def __init__(self, identifier):
super().__init__(identifier)
get_first = lambda a: a[0]
get_remote_id = lambda a: self.base_url + a
self.book_mappings = [
Mapping('title'),
Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping("title"),
Mapping("id", remote_field="key", formatter=get_remote_id),
Mapping("cover", remote_field="covers", formatter=self.get_cover_url),
Mapping("sortTitle", remote_field="sort_title"),
Mapping("subtitle"),
Mapping("description", formatter=get_description),
Mapping("languages", formatter=get_languages),
Mapping("series", formatter=get_first),
Mapping("seriesNumber", remote_field="series_number"),
Mapping("subjects"),
Mapping("subjectPlaces", remote_field="subject_places"),
Mapping("isbn13", remote_field="isbn_13", formatter=get_first),
Mapping("isbn10", remote_field="isbn_10", formatter=get_first),
Mapping("lccn", formatter=get_first),
Mapping("oclcNumber", remote_field="oclc_numbers", formatter=get_first),
Mapping(
'cover', remote_field='covers', formatter=self.get_cover_url),
Mapping('sortTitle', remote_field='sort_title'),
Mapping('subtitle'),
Mapping('description', formatter=get_description),
Mapping('languages', formatter=get_languages),
Mapping('series', formatter=get_first),
Mapping('seriesNumber', remote_field='series_number'),
Mapping('subjects'),
Mapping('subjectPlaces', remote_field='subject_places'),
Mapping('isbn13', remote_field='isbn_13', formatter=get_first),
Mapping('isbn10', remote_field='isbn_10', formatter=get_first),
Mapping('lccn', formatter=get_first),
Mapping(
'oclcNumber', remote_field='oclc_numbers',
formatter=get_first
"openlibraryKey", remote_field="key", formatter=get_openlibrary_key
),
Mapping("goodreadsKey", remote_field="goodreads_key"),
Mapping("asin"),
Mapping(
'openlibraryKey', remote_field='key',
formatter=get_openlibrary_key
"firstPublishedDate",
remote_field="first_publish_date",
),
Mapping('goodreadsKey', remote_field='goodreads_key'),
Mapping('asin'),
Mapping(
'firstPublishedDate', remote_field='first_publish_date',
),
Mapping('publishedDate', remote_field='publish_date'),
Mapping('pages', remote_field='number_of_pages'),
Mapping('physicalFormat', remote_field='physical_format'),
Mapping('publishers'),
Mapping("publishedDate", remote_field="publish_date"),
Mapping("pages", remote_field="number_of_pages"),
Mapping("physicalFormat", remote_field="physical_format"),
Mapping("publishers"),
]
self.author_mappings = [
Mapping('id', remote_field='key', formatter=get_remote_id),
Mapping('name'),
Mapping("id", remote_field="key", formatter=get_remote_id),
Mapping("name"),
Mapping(
'openlibraryKey', remote_field='key',
formatter=get_openlibrary_key
"openlibraryKey", remote_field="key", formatter=get_openlibrary_key
),
Mapping('born', remote_field='birth_date'),
Mapping('died', remote_field='death_date'),
Mapping('bio', formatter=get_description),
Mapping("born", remote_field="birth_date"),
Mapping("died", remote_field="death_date"),
Mapping("bio", formatter=get_description),
]
def get_remote_id_from_data(self, data):
''' format a url from an openlibrary id field '''
""" format a url from an openlibrary id field """
try:
key = data['key']
key = data["key"]
except KeyError:
raise ConnectorException('Invalid book data')
return '%s%s' % (self.books_url, key)
raise ConnectorException("Invalid book data")
return "%s%s" % (self.books_url, key)
def is_work_data(self, data):
return bool(re.match(r'^[\/\w]+OL\d+W$', data['key']))
return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"]))
def get_edition_from_work_data(self, data):
try:
key = data['key']
key = data["key"]
except KeyError:
raise ConnectorException('Invalid book data')
url = '%s%s/editions' % (self.books_url, key)
raise ConnectorException("Invalid book data")
url = "%s%s/editions" % (self.books_url, key)
data = get_data(url)
return pick_default_edition(data['entries'])
return pick_default_edition(data["entries"])
def get_work_from_edition_data(self, data):
try:
key = data['works'][0]['key']
key = data["works"][0]["key"]
except (IndexError, KeyError):
raise ConnectorException('No work found for edition')
url = '%s%s' % (self.books_url, key)
raise ConnectorException("No work found for edition")
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)
""" 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"
author_id = author_blob['key']
url = '%s%s' % (self.base_url, author_id)
author_id = author_blob["key"]
url = "%s%s" % (self.base_url, author_id)
yield self.get_or_create_author(url)
def get_cover_url(self, cover_blob):
''' ask openlibrary for the cover '''
""" ask openlibrary for the cover """
cover_id = cover_blob[0]
image_name = '%s-L.jpg' % cover_id
return '%s/b/id/%s' % (self.covers_url, image_name)
image_name = "%s-L.jpg" % cover_id
return "%s/b/id/%s" % (self.covers_url, image_name)
def parse_search_data(self, data):
return data.get('docs')
return data.get("docs")
def format_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result['key']
author = search_result.get('author_name') or ['Unknown']
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
return SearchResult(
title=search_result.get('title'),
title=search_result.get("title"),
key=key,
author=', '.join(author),
author=", ".join(author),
connector=self,
year=search_result.get('first_publish_year'),
year=search_result.get("first_publish_year"),
)
def parse_isbn_search_data(self, data):
return list(data.values())
def format_isbn_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result['key']
authors = search_result.get('authors') or [{'name': 'Unknown'}]
author_names = [ author.get('name') for author in authors]
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
return SearchResult(
title=search_result.get('title'),
title=search_result.get("title"),
key=key,
author=', '.join(author_names),
author=", ".join(author_names),
connector=self,
year=search_result.get('publish_date'),
year=search_result.get("publish_date"),
)
def load_edition_data(self, olkey):
''' query openlibrary for editions of a work '''
url = '%s/works/%s/editions' % (self.books_url, olkey)
""" query openlibrary for editions of a work """
url = "%s/works/%s/editions" % (self.books_url, olkey)
return get_data(url)
def expand_book_data(self, book):
work = book
# go from the edition to the work, if necessary
@ -164,7 +150,7 @@ class Connector(AbstractConnector):
# who knows, man
return
for edition_data in edition_options.get('entries'):
for edition_data in edition_options.get("entries"):
# does this edition have ANY interesting data?
if ignore_edition(edition_data):
continue
@ -172,62 +158,63 @@ class Connector(AbstractConnector):
def ignore_edition(edition_data):
''' don't load a million editions that have no metadata '''
""" don't load a million editions that have no metadata """
# an isbn, we love to see it
if edition_data.get('isbn_13') or edition_data.get('isbn_10'):
print(edition_data.get('isbn_10'))
if edition_data.get("isbn_13") or edition_data.get("isbn_10"):
print(edition_data.get("isbn_10"))
return False
# grudgingly, oclc can stay
if edition_data.get('oclc_numbers'):
print(edition_data.get('oclc_numbers'))
if edition_data.get("oclc_numbers"):
print(edition_data.get("oclc_numbers"))
return False
# if it has a cover it can stay
if edition_data.get('covers'):
print(edition_data.get('covers'))
if edition_data.get("covers"):
print(edition_data.get("covers"))
return False
# keep non-english editions
if edition_data.get('languages') and \
'languages/eng' not in str(edition_data.get('languages')):
print(edition_data.get('languages'))
if edition_data.get("languages") and "languages/eng" not in str(
edition_data.get("languages")
):
print(edition_data.get("languages"))
return False
return True
def get_description(description_blob):
''' descriptions can be a string or a dict '''
""" descriptions can be a string or a dict """
if isinstance(description_blob, dict):
return description_blob.get('value')
return description_blob.get("value")
return description_blob
def get_openlibrary_key(key):
''' convert /books/OL27320736M into OL27320736M '''
return key.split('/')[-1]
""" convert /books/OL27320736M into OL27320736M """
return key.split("/")[-1]
def get_languages(language_blob):
''' /language/eng -> English '''
""" /language/eng -> English """
langs = []
for lang in language_blob:
langs.append(
languages.get(lang.get('key', ''), None)
)
langs.append(languages.get(lang.get("key", ""), None))
return langs
def pick_default_edition(options):
''' favor physical copies with covers in english '''
""" 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('covers')] 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
options = [e for e in options if e.get("covers")] or options
options = [
e for e in options if "/languages/eng" in str(e.get("languages"))
] or options
formats = ["paperback", "hardcover", "mass market paperback"]
options = [
e for e in options if str(e.get("physical_format")).lower() in formats
] or options
options = [e for e in options if e.get("isbn_13")] or options
options = [e for e in options if e.get("ocaid")] or options
return options[0]

View file

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

View file

@ -1,4 +1,4 @@
''' using a bookwyrm instance as a source of book data '''
""" using a bookwyrm instance as a source of book data """
from functools import reduce
import operator
@ -10,10 +10,11 @@ from .abstract_connector import AbstractConnector, SearchResult
class Connector(AbstractConnector):
''' instantiate a connector '''
""" instantiate a connector """
# pylint: disable=arguments-differ
def search(self, query, min_confidence=0.1, raw=False):
''' search your local database '''
""" search your local database """
if not query:
return []
# first, try searching unqiue identifiers
@ -34,19 +35,18 @@ class Connector(AbstractConnector):
return search_results
def isbn_search(self, query, raw=False):
''' search your local database '''
""" search your local database """
if not query:
return []
filters = [{f: query} for f in ['isbn_10', 'isbn_13']]
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
results = models.Edition.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters))
).distinct()
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
results = results.filter(parent_work__default_edition__id=F('id')) \
or results
results = results.filter(parent_work__default_edition__id=F("id")) or results
search_results = []
for result in results:
@ -58,33 +58,30 @@ class Connector(AbstractConnector):
break
return search_results
def format_search_result(self, search_result):
return SearchResult(
title=search_result.title,
key=search_result.remote_id,
author=search_result.author_text,
year=search_result.published_date.year if \
search_result.published_date else None,
year=search_result.published_date.year
if search_result.published_date
else None,
connector=self,
confidence=search_result.rank if \
hasattr(search_result, 'rank') else 1,
confidence=search_result.rank if hasattr(search_result, "rank") else 1,
)
def format_isbn_search_result(self, search_result):
return SearchResult(
title=search_result.title,
key=search_result.remote_id,
author=search_result.author_text,
year=search_result.published_date.year if \
search_result.published_date else None,
year=search_result.published_date.year
if search_result.published_date
else None,
connector=self,
confidence=search_result.rank if \
hasattr(search_result, 'rank') else 1,
confidence=search_result.rank if hasattr(search_result, "rank") else 1,
)
def is_work_data(self, data):
pass
@ -98,11 +95,11 @@ class Connector(AbstractConnector):
return None
def parse_isbn_search_data(self, data):
''' it's already in the right format, don't even worry about it '''
""" it's already in the right format, don't even worry about it """
return data
def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it '''
""" it's already in the right format, don't even worry about it """
return data
def expand_book_data(self, book):
@ -110,44 +107,47 @@ class Connector(AbstractConnector):
def search_identifiers(query):
''' tries remote_id, isbn; defined as dedupe fields on the model '''
filters = [{f.name: query} for f in models.Edition._meta.get_fields() \
if hasattr(f, 'deduplication_field') and f.deduplication_field]
""" tries remote_id, isbn; defined as dedupe fields on the model """
filters = [
{f.name: query}
for f in models.Edition._meta.get_fields()
if hasattr(f, "deduplication_field") and f.deduplication_field
]
results = models.Edition.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters))
).distinct()
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
return results.filter(parent_work__default_edition__id=F('id')) \
or results
return results.filter(parent_work__default_edition__id=F("id")) or results
def search_title_author(query, min_confidence):
''' searches for title and author '''
vector = SearchVector('title', weight='A') +\
SearchVector('subtitle', weight='B') +\
SearchVector('authors__name', weight='C') +\
SearchVector('series', weight='D')
""" searches for title and author """
vector = (
SearchVector("title", weight="A")
+ SearchVector("subtitle", weight="B")
+ SearchVector("authors__name", weight="C")
+ SearchVector("series", weight="D")
)
results = models.Edition.objects.annotate(
search=vector
).annotate(
rank=SearchRank(vector, query)
).filter(
rank__gt=min_confidence
).order_by('-rank')
results = (
models.Edition.objects.annotate(search=vector)
.annotate(rank=SearchRank(vector, query))
.filter(rank__gt=min_confidence)
.order_by("-rank")
)
# when there are multiple editions of the same work, pick the closest
editions_of_work = results.values(
'parent_work'
).annotate(
Count('parent_work')
).values_list('parent_work')
editions_of_work = (
results.values("parent_work")
.annotate(Count("parent_work"))
.values_list("parent_work")
)
for work_id in set(editions_of_work):
editions = results.filter(parent_work=work_id)
default = editions.filter(parent_work__default_edition=F('id'))
default = editions.filter(parent_work__default_edition=F("id"))
default_rank = default.first().rank if default.exists() else 0
# if mutliple books have the top rank, pick the default edition
if default_rank == editions.first().rank:

View file

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

View file

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

View file

@ -1,25 +1,27 @@
''' send emails '''
""" send emails """
from django.core.mail import send_mail
from bookwyrm import models
from bookwyrm.tasks import app
def password_reset_email(reset_code):
''' generate a password reset email '''
""" generate a password reset email """
site = models.SiteSettings.get()
send_email.delay(
reset_code.user.email,
'Reset your password on %s' % site.name,
'Your password reset link: %s' % reset_code.link
"Reset your password on %s" % site.name,
"Your password reset link: %s" % reset_code.link,
)
@app.task
def send_email(recipient, subject, message):
''' use a task to send the email '''
""" use a task to send the email """
send_mail(
subject,
message,
None, # sender will be the config default
[recipient],
fail_silently=False
fail_silently=False,
)

View file

@ -1,4 +1,4 @@
''' using django model forms '''
""" using django model forms """
import datetime
from collections import defaultdict
@ -12,115 +12,125 @@ from bookwyrm import models
class CustomForm(ModelForm):
''' add css classes to the forms '''
""" add css classes to the forms """
def __init__(self, *args, **kwargs):
css_classes = defaultdict(lambda: '')
css_classes['text'] = 'input'
css_classes['password'] = 'input'
css_classes['email'] = 'input'
css_classes['number'] = 'input'
css_classes['checkbox'] = 'checkbox'
css_classes['textarea'] = 'textarea'
css_classes = defaultdict(lambda: "")
css_classes["text"] = "input"
css_classes["password"] = "input"
css_classes["email"] = "input"
css_classes["number"] = "input"
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
super(CustomForm, self).__init__(*args, **kwargs)
for visible in self.visible_fields():
if hasattr(visible.field.widget, 'input_type'):
if hasattr(visible.field.widget, "input_type"):
input_type = visible.field.widget.input_type
if isinstance(visible.field.widget, Textarea):
input_type = 'textarea'
visible.field.widget.attrs['cols'] = None
visible.field.widget.attrs['rows'] = None
visible.field.widget.attrs['class'] = css_classes[input_type]
input_type = "textarea"
visible.field.widget.attrs["cols"] = None
visible.field.widget.attrs["rows"] = None
visible.field.widget.attrs["class"] = css_classes[input_type]
# pylint: disable=missing-class-docstring
class LoginForm(CustomForm):
class Meta:
model = models.User
fields = ['localname', 'password']
fields = ["localname", "password"]
help_texts = {f: None for f in fields}
widgets = {
'password': PasswordInput(),
"password": PasswordInput(),
}
class RegisterForm(CustomForm):
class Meta:
model = models.User
fields = ['localname', 'email', 'password']
fields = ["localname", "email", "password"]
help_texts = {f: None for f in fields}
widgets = {
'password': PasswordInput()
}
widgets = {"password": PasswordInput()}
class RatingForm(CustomForm):
class Meta:
model = models.Review
fields = ['user', 'book', 'content', 'rating', 'privacy']
fields = ["user", "book", "content", "rating", "privacy"]
class ReviewForm(CustomForm):
class Meta:
model = models.Review
fields = [
'user', 'book',
'name', 'content', 'rating',
'content_warning', 'sensitive',
'privacy']
"user",
"book",
"name",
"content",
"rating",
"content_warning",
"sensitive",
"privacy",
]
class CommentForm(CustomForm):
class Meta:
model = models.Comment
fields = [
'user', 'book', 'content',
'content_warning', 'sensitive',
'privacy']
fields = ["user", "book", "content", "content_warning", "sensitive", "privacy"]
class QuotationForm(CustomForm):
class Meta:
model = models.Quotation
fields = [
'user', 'book', 'quote', 'content',
'content_warning', 'sensitive', 'privacy']
"user",
"book",
"quote",
"content",
"content_warning",
"sensitive",
"privacy",
]
class ReplyForm(CustomForm):
class Meta:
model = models.Status
fields = [
'user', 'content', 'content_warning', 'sensitive',
'reply_parent', 'privacy']
"user",
"content",
"content_warning",
"sensitive",
"reply_parent",
"privacy",
]
class StatusForm(CustomForm):
class Meta:
model = models.Status
fields = [
'user', 'content', 'content_warning', 'sensitive', 'privacy']
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
class EditUserForm(CustomForm):
class Meta:
model = models.User
fields = [
'avatar', 'name', 'email', 'summary', 'manually_approves_followers'
]
fields = ["avatar", "name", "email", "summary", "manually_approves_followers"]
help_texts = {f: None for f in fields}
class TagForm(CustomForm):
class Meta:
model = models.Tag
fields = ['name']
fields = ["name"]
help_texts = {f: None for f in fields}
labels = {'name': 'Add a tag'}
labels = {"name": "Add a tag"}
class CoverForm(CustomForm):
class Meta:
model = models.Book
fields = ['cover']
fields = ["cover"]
help_texts = {f: None for f in fields}
@ -128,80 +138,87 @@ class EditionForm(CustomForm):
class Meta:
model = models.Edition
exclude = [
'remote_id',
'origin_id',
'created_date',
'updated_date',
'edition_rank',
'authors',# TODO
'parent_work',
'shelves',
'subjects',# TODO
'subject_places',# TODO
'connector',
"remote_id",
"origin_id",
"created_date",
"updated_date",
"edition_rank",
"authors", # TODO
"parent_work",
"shelves",
"subjects", # TODO
"subject_places", # TODO
"connector",
]
class AuthorForm(CustomForm):
class Meta:
model = models.Author
exclude = [
'remote_id',
'origin_id',
'created_date',
'updated_date',
"remote_id",
"origin_id",
"created_date",
"updated_date",
]
class ImportForm(forms.Form):
csv_file = forms.FileField()
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
''' human-readable exiration time buckets '''
""" human-readable exiration time buckets """
selected_string = super().value_from_datadict(data, files, name)
if selected_string == 'day':
if selected_string == "day":
interval = datetime.timedelta(days=1)
elif selected_string == 'week':
elif selected_string == "week":
interval = datetime.timedelta(days=7)
elif selected_string == 'month':
elif selected_string == "month":
interval = datetime.timedelta(days=31) # Close enough?
elif selected_string == 'forever':
elif selected_string == "forever":
return None
else:
return selected_string # "This will raise
return timezone.now() + interval
class CreateInviteForm(CustomForm):
class Meta:
model = models.SiteInvite
exclude = ['code', 'user', 'times_used']
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, _("%(count)d uses" % {'count': i})) \
for i in [1, 5, 10, 25, 50, 100]]
+ [(None, _('Unlimited'))])
"expiry": ExpiryWidget(
choices=[
("day", _("One Day")),
("week", _("One Week")),
("month", _("One Month")),
("forever", _("Does Not Expire")),
]
),
"use_limit": widgets.Select(
choices=[
(i, _("%(count)d uses" % {"count": i}))
for i in [1, 5, 10, 25, 50, 100]
]
+ [(None, _("Unlimited"))]
),
}
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ['user', 'name', 'privacy']
fields = ["user", "name", "privacy"]
class GoalForm(CustomForm):
class Meta:
model = models.AnnualGoal
fields = ['user', 'year', 'goal', 'privacy']
fields = ["user", "year", "goal", "privacy"]
class SiteForm(CustomForm):
@ -213,4 +230,4 @@ class SiteForm(CustomForm):
class ListForm(CustomForm):
class Meta:
model = models.List
fields = ['user', 'name', 'description', 'curation', 'privacy']
fields = ["user", "name", "description", "curation", "privacy"]

View file

@ -1,13 +1,14 @@
''' handle reading a csv from goodreads '''
""" handle reading a csv from goodreads """
from bookwyrm.importer import Importer
# GoodReads is the default importer, thus Importer follows its structure. For a more complete example of overriding see librarything_import.py
class GoodreadsImporter(Importer):
service = 'GoodReads'
service = "GoodReads"
def parse_fields(self, data):
data.update({'import_source': self.service })
data.update({"import_source": self.service})
# add missing 'Date Started' field
data.update({'Date Started': None })
data.update({"Date Started": None})
return data

View file

@ -1,4 +1,4 @@
''' handle reading a csv from an external service, defaults are from GoodReads '''
""" handle reading a csv from an external service, defaults are from GoodReads """
import csv
import logging
@ -8,49 +8,48 @@ from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
class Importer:
service = 'Unknown'
delimiter = ','
encoding = 'UTF-8'
mandatory_fields = ['Title', 'Author']
service = "Unknown"
delimiter = ","
encoding = "UTF-8"
mandatory_fields = ["Title", "Author"]
def create_job(self, user, csv_file, include_reviews, privacy):
''' check over a csv and creates a database entry for the job'''
""" check over a csv and creates a database entry for the job"""
job = ImportJob.objects.create(
user=user,
include_reviews=include_reviews,
privacy=privacy
user=user, include_reviews=include_reviews, privacy=privacy
)
for index, entry in enumerate(list(csv.DictReader(csv_file, delimiter=self.delimiter ))):
for index, entry in enumerate(
list(csv.DictReader(csv_file, delimiter=self.delimiter))
):
if not all(x in entry for x in self.mandatory_fields):
raise ValueError('Author and title must be in data.')
raise ValueError("Author and title must be in data.")
entry = self.parse_fields(entry)
self.save_item(job, index, entry)
return job
def save_item(self, job, index, data):
ImportItem(job=job, index=index, data=data).save()
def parse_fields(self, entry):
entry.update({'import_source': self.service })
entry.update({"import_source": self.service})
return entry
def create_retry_job(self, user, original_job, items):
''' retry items that didn't import '''
""" retry items that didn't import """
job = ImportJob.objects.create(
user=user,
include_reviews=original_job.include_reviews,
privacy=original_job.privacy,
retry=True
retry=True,
)
for item in items:
self.save_item(job, item.index, item.data)
return job
def start_import(self, job):
''' initalizes a csv import job '''
""" initalizes a csv import job """
result = import_data.delay(self.service, job.id)
job.task_id = result.id
job.save()
@ -58,15 +57,15 @@ class Importer:
@app.task
def import_data(source, job_id):
''' does the actual lookup work in a celery task '''
""" does the actual lookup work in a celery task """
job = ImportJob.objects.get(id=job_id)
try:
for item in job.items.all():
try:
item.resolve()
except Exception as e:# pylint: disable=broad-except
except Exception as e: # pylint: disable=broad-except
logger.exception(e)
item.fail_reason = 'Error loading book'
item.fail_reason = "Error loading book"
item.save()
continue
@ -74,10 +73,11 @@ def import_data(source, job_id):
item.save()
# shelves book and handles reviews
handle_imported_book(source,
job.user, item, job.include_reviews, job.privacy)
handle_imported_book(
source, job.user, item, job.include_reviews, job.privacy
)
else:
item.fail_reason = 'Could not find a match for book'
item.fail_reason = "Could not find a match for book"
item.save()
finally:
job.complete = True
@ -85,30 +85,26 @@ def import_data(source, job_id):
def handle_imported_book(source, user, item, include_reviews, privacy):
''' process a csv and then post about it '''
""" process a csv and then post about it """
if isinstance(item.book, models.Work):
item.book = item.book.default_edition
if not item.book:
return
existing_shelf = models.ShelfBook.objects.filter(
book=item.book, user=user).exists()
existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
# shelve the book if it hasn't been shelved already
if item.shelf and not existing_shelf:
desired_shelf = models.Shelf.objects.get(
identifier=item.shelf,
user=user
)
models.ShelfBook.objects.create(
book=item.book, shelf=desired_shelf, user=user)
desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
models.ShelfBook.objects.create(book=item.book, shelf=desired_shelf, user=user)
for read in item.reads:
# check for an existing readthrough with the same dates
if models.ReadThrough.objects.filter(
user=user, book=item.book,
user=user,
book=item.book,
start_date=read.start_date,
finish_date=read.finish_date
finish_date=read.finish_date,
).exists():
continue
read.book = item.book
@ -116,10 +112,14 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
read.save()
if include_reviews and (item.rating or item.review):
review_title = 'Review of {!r} on {!r}'.format(
review_title = (
"Review of {!r} on {!r}".format(
item.book.title,
source,
) if item.review else ''
)
if item.review
else ""
)
# we don't know the publication date of the review,
# but "now" is a bad guess

View file

@ -1,4 +1,4 @@
''' handle reading a csv from librarything '''
""" handle reading a csv from librarything """
import csv
import re
import math
@ -9,34 +9,34 @@ from bookwyrm.importer import Importer
class LibrarythingImporter(Importer):
service = 'LibraryThing'
delimiter = '\t'
encoding = 'ISO-8859-1'
service = "LibraryThing"
delimiter = "\t"
encoding = "ISO-8859-1"
# mandatory_fields : fields matching the book title and author
mandatory_fields = ['Title', 'Primary Author']
mandatory_fields = ["Title", "Primary Author"]
def parse_fields(self, initial):
data = {}
data['import_source'] = self.service
data['Book Id'] = initial['Book Id']
data['Title'] = initial['Title']
data['Author'] = initial['Primary Author']
data['ISBN13'] = initial['ISBN']
data['My Review'] = initial['Review']
if initial['Rating']:
data['My Rating'] = math.ceil(float(initial['Rating']))
data["import_source"] = self.service
data["Book Id"] = initial["Book Id"]
data["Title"] = initial["Title"]
data["Author"] = initial["Primary Author"]
data["ISBN13"] = initial["ISBN"]
data["My Review"] = initial["Review"]
if initial["Rating"]:
data["My Rating"] = math.ceil(float(initial["Rating"]))
else:
data['My Rating'] = ''
data['Date Added'] = re.sub('\[|\]', '', initial['Entry Date'])
data['Date Started'] = re.sub('\[|\]', '', initial['Date Started'])
data['Date Read'] = re.sub('\[|\]', '', initial['Date Read'])
data["My Rating"] = ""
data["Date Added"] = re.sub("\[|\]", "", initial["Entry Date"])
data["Date Started"] = re.sub("\[|\]", "", initial["Date Started"])
data["Date Read"] = re.sub("\[|\]", "", initial["Date Read"])
data['Exclusive Shelf'] = None
if data['Date Read']:
data['Exclusive Shelf'] = "read"
elif data['Date Started']:
data['Exclusive Shelf'] = "reading"
data["Exclusive Shelf"] = None
if data["Date Read"]:
data["Exclusive Shelf"] = "read"
elif data["Date Started"]:
data["Exclusive Shelf"] = "reading"
else:
data['Exclusive Shelf'] = "to-read"
data["Exclusive Shelf"] = "to-read"
return data

View file

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

View file

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

View file

@ -1,34 +1,42 @@
''' PROCEED WITH CAUTION: this permanently deletes book data '''
""" PROCEED WITH CAUTION: this permanently deletes book data """
from django.core.management.base import BaseCommand
from django.db.models import Count, Q
from bookwyrm import models
def remove_editions():
''' combine duplicate editions and update related models '''
""" combine duplicate editions and update related models """
# not in use
filters = {'%s__isnull' % r.name: True \
for r in models.Edition._meta.related_objects}
filters = {
"%s__isnull" % r.name: True for r in models.Edition._meta.related_objects
}
# no cover, no identifying fields
filters['cover'] = ''
null_fields = {'%s__isnull' % f: True for f in \
['isbn_10', 'isbn_13', 'oclc_number']}
filters["cover"] = ""
null_fields = {
"%s__isnull" % f: True for f in ["isbn_10", "isbn_13", "oclc_number"]
}
editions = models.Edition.objects.filter(
Q(languages=[]) | Q(languages__contains=['English']),
**filters, **null_fields
).annotate(Count('parent_work__editions')).filter(
editions = (
models.Edition.objects.filter(
Q(languages=[]) | Q(languages__contains=["English"]),
**filters,
**null_fields
)
.annotate(Count("parent_work__editions"))
.filter(
# mustn't be the only edition for the work
parent_work__editions__count__gt=1
)
)
print(editions.count())
editions.delete()
class Command(BaseCommand):
''' dedplucate allllll the book data models '''
help = 'merges duplicate book data'
""" dedplucate allllll the book data models """
help = "merges duplicate book data"
# pylint: disable=no-self-use,unused-argument
def handle(self, *args, **options):
''' run deudplications '''
""" run deudplications """
remove_editions()

View file

@ -15,199 +15,448 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0011_update_proxy_permissions'),
("auth", "0011_update_proxy_permissions"),
]
operations = [
migrations.CreateModel(
name='User',
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/')),
(
"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,
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='Author',
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', JSONField()),
(
"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", JSONField()),
],
options={
'abstract': False,
"abstract": False,
},
),
migrations.CreateModel(
name='Book',
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', 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='bookwyrm.Author')),
(
"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", 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="bookwyrm.Author")),
],
options={
'abstract': False,
"abstract": False,
},
),
migrations.CreateModel(
name='FederatedServer',
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)),
(
"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,
"abstract": False,
},
),
migrations.CreateModel(
name='Shelf',
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)),
(
"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',
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='bookwyrm.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='bookwyrm.Status')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
(
"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="bookwyrm.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="bookwyrm.Status",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'abstract': False,
"abstract": False,
},
),
migrations.CreateModel(
name='UserRelationship',
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)),
(
"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,
"abstract": False,
},
),
migrations.CreateModel(
name='ShelfBook',
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='bookwyrm.Book')),
('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Shelf')),
(
"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="bookwyrm.Book"
),
),
(
"shelf",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Shelf"
),
),
],
options={
'unique_together': {('book', 'shelf')},
"unique_together": {("book", "shelf")},
},
),
migrations.AddField(
model_name='shelf',
name='books',
field=models.ManyToManyField(through='bookwyrm.ShelfBook', to='bookwyrm.Book'),
model_name="shelf",
name="books",
field=models.ManyToManyField(
through="bookwyrm.ShelfBook", to="bookwyrm.Book"
),
),
migrations.AddField(
model_name='shelf',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
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='bookwyrm.ShelfBook', to='bookwyrm.Shelf'),
model_name="book",
name="shelves",
field=models.ManyToManyField(
through="bookwyrm.ShelfBook", to="bookwyrm.Shelf"
),
),
migrations.AddField(
model_name='user',
name='federated_server',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.FederatedServer'),
model_name="user",
name="federated_server",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.FederatedServer",
),
),
migrations.AddField(
model_name='user',
name='followers',
field=models.ManyToManyField(through='bookwyrm.UserRelationship', to=settings.AUTH_USER_MODEL),
model_name="user",
name="followers",
field=models.ManyToManyField(
through="bookwyrm.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'),
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'),
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')},
name="shelf",
unique_together={("user", "identifier")},
),
migrations.CreateModel(
name='Review',
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='bookwyrm.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='bookwyrm.Book')),
(
"status_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookwyrm.Status",
),
),
("name", models.CharField(max_length=255)),
(
"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="bookwyrm.Book"
),
),
],
options={
'abstract': False,
"abstract": False,
},
bases=('bookwyrm.status',),
bases=("bookwyrm.status",),
),
]

View file

@ -8,31 +8,59 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0001_initial'),
("bookwyrm", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='Favorite',
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='bookwyrm.Status')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
(
"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="bookwyrm.Status",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'unique_together': {('user', 'status')},
"unique_together": {("user", "status")},
},
),
migrations.AddField(
model_name='status',
name='favorites',
field=models.ManyToManyField(related_name='user_favorites', through='bookwyrm.Favorite', to=settings.AUTH_USER_MODEL),
model_name="status",
name="favorites",
field=models.ManyToManyField(
related_name="user_favorites",
through="bookwyrm.Favorite",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name='user',
name='favorites',
field=models.ManyToManyField(related_name='favorite_statuses', through='bookwyrm.Favorite', to='bookwyrm.Status'),
model_name="user",
name="favorites",
field=models.ManyToManyField(
related_name="favorite_statuses",
through="bookwyrm.Favorite",
to="bookwyrm.Status",
),
),
]

View file

@ -7,87 +7,89 @@ import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0002_auto_20200219_0816'),
("bookwyrm", "0002_auto_20200219_0816"),
]
operations = [
migrations.RemoveField(
model_name='author',
name='content',
model_name="author",
name="content",
),
migrations.RemoveField(
model_name='book',
name='content',
model_name="book",
name="content",
),
migrations.RemoveField(
model_name='favorite',
name='content',
model_name="favorite",
name="content",
),
migrations.RemoveField(
model_name='federatedserver',
name='content',
model_name="federatedserver",
name="content",
),
migrations.RemoveField(
model_name='shelf',
name='content',
model_name="shelf",
name="content",
),
migrations.RemoveField(
model_name='shelfbook',
name='content',
model_name="shelfbook",
name="content",
),
migrations.RemoveField(
model_name='userrelationship',
name='content',
model_name="userrelationship",
name="content",
),
migrations.AddField(
model_name='author',
name='updated_date',
model_name="author",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='book',
name='updated_date',
model_name="book",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='favorite',
name='updated_date',
model_name="favorite",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='federatedserver',
name='updated_date',
model_name="federatedserver",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='shelf',
name='updated_date',
model_name="shelf",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='shelfbook',
name='updated_date',
model_name="shelfbook",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='status',
name='updated_date',
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),
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',
model_name="user",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='userrelationship',
name='updated_date',
model_name="userrelationship",
name="updated_date",
field=models.DateTimeField(auto_now=True),
),
]

View file

@ -8,22 +8,41 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0003_auto_20200221_0131'),
("bookwyrm", "0003_auto_20200221_0131"),
]
operations = [
migrations.CreateModel(
name='Tag',
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='bookwyrm.Book')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
(
"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="bookwyrm.Book"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'unique_together': {('user', 'book', 'name')},
"unique_together": {("user", "book", "name")},
},
),
]

View file

@ -5,27 +5,27 @@ from django.db import migrations, models
def populate_identifiers(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
tags = app_registry.get_model('bookwyrm', 'Tag')
tags = app_registry.get_model("bookwyrm", "Tag")
for tag in tags.objects.using(db_alias):
tag.identifier = re.sub(r'\W+', '-', tag.name).lower()
tag.identifier = re.sub(r"\W+", "-", tag.name).lower()
tag.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0004_tag'),
("bookwyrm", "0004_tag"),
]
operations = [
migrations.AddField(
model_name='tag',
name='identifier',
model_name="tag",
name="identifier",
field=models.CharField(max_length=100, null=True),
),
migrations.AlterField(
model_name='tag',
name='name',
model_name="tag",
name="name",
field=models.CharField(max_length=100),
),
migrations.RunPython(populate_identifiers),

View file

@ -8,13 +8,15 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0006_auto_20200221_1702_squashed_0064_merge_20201101_1913'),
("bookwyrm", "0006_auto_20200221_1702_squashed_0064_merge_20201101_1913"),
]
operations = [
migrations.AlterField(
model_name='siteinvite',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
model_name="siteinvite",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
]

View file

@ -6,8 +6,8 @@ import django.db.models.deletion
def set_default_edition(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
works = app_registry.get_model('bookwyrm', 'Work').objects.using(db_alias)
editions = app_registry.get_model('bookwyrm', 'Edition').objects.using(db_alias)
works = app_registry.get_model("bookwyrm", "Work").objects.using(db_alias)
editions = app_registry.get_model("bookwyrm", "Edition").objects.using(db_alias)
for work in works:
ed = editions.filter(parent_work=work, default=True).first()
if not ed:
@ -15,21 +15,26 @@ def set_default_edition(app_registry, schema_editor):
work.default_edition = ed
work.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0007_auto_20201103_0014'),
("bookwyrm", "0007_auto_20201103_0014"),
]
operations = [
migrations.AddField(
model_name='work',
name='default_edition',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
model_name="work",
name="default_edition",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Edition",
),
),
migrations.RunPython(set_default_edition),
migrations.RemoveField(
model_name='edition',
name='default',
model_name="edition",
name="default",
),
]

View file

@ -6,13 +6,22 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0008_work_default_edition'),
("bookwyrm", "0008_work_default_edition"),
]
operations = [
migrations.AddField(
model_name='shelf',
name='privacy',
field=models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
model_name="shelf",
name="privacy",
field=models.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0009_shelf_privacy'),
("bookwyrm", "0009_shelf_privacy"),
]
operations = [
migrations.AddField(
model_name='importjob',
name='retry',
model_name="importjob",
name="retry",
field=models.BooleanField(default=False),
),
]

View file

@ -2,9 +2,10 @@
from django.db import migrations, models
def set_origin_id(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
books = app_registry.get_model('bookwyrm', 'Book').objects.using(db_alias)
books = app_registry.get_model("bookwyrm", "Book").objects.using(db_alias)
for book in books:
book.origin_id = book.remote_id
# the remote_id will be set automatically
@ -15,18 +16,18 @@ def set_origin_id(app_registry, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0010_importjob_retry'),
("bookwyrm", "0010_importjob_retry"),
]
operations = [
migrations.AddField(
model_name='author',
name='origin_id',
model_name="author",
name="origin_id",
field=models.CharField(max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='origin_id',
model_name="book",
name="origin_id",
field=models.CharField(max_length=255, null=True),
),
migrations.RunPython(set_origin_id),

View file

@ -7,23 +7,41 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0011_auto_20201113_1727'),
("bookwyrm", "0011_auto_20201113_1727"),
]
operations = [
migrations.CreateModel(
name='Attachment',
name="Attachment",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', models.CharField(max_length=255, null=True)),
('image', models.ImageField(blank=True, null=True, upload_to='status/')),
('caption', models.TextField(blank=True, null=True)),
('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
("remote_id", models.CharField(max_length=255, null=True)),
(
"image",
models.ImageField(blank=True, null=True, upload_to="status/"),
),
("caption", models.TextField(blank=True, null=True)),
(
"status",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="bookwyrm.Status",
),
),
],
options={
'abstract': False,
"abstract": False,
},
),
]

View file

@ -8,24 +8,51 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0011_auto_20201113_1727'),
("bookwyrm", "0011_auto_20201113_1727"),
]
operations = [
migrations.CreateModel(
name='ProgressUpdate',
name="ProgressUpdate",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', models.CharField(max_length=255, null=True)),
('progress', models.IntegerField()),
('mode', models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3)),
('readthrough', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.ReadThrough')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
("remote_id", models.CharField(max_length=255, null=True)),
("progress", models.IntegerField()),
(
"mode",
models.CharField(
choices=[("PG", "page"), ("PCT", "percent")],
default="PG",
max_length=3,
),
),
(
"readthrough",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.ReadThrough",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'abstract': False,
"abstract": False,
},
),
]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0012_attachment'),
("bookwyrm", "0012_attachment"),
]
operations = [
migrations.AlterField(
model_name='book',
name='origin_id',
model_name="book",
name="origin_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0013_book_origin_id'),
("bookwyrm", "0013_book_origin_id"),
]
operations = [
migrations.RenameModel(
old_name='Attachment',
new_name='Image',
old_name="Attachment",
new_name="Image",
),
]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0013_book_origin_id'),
('bookwyrm', '0012_progressupdate'),
("bookwyrm", "0013_book_origin_id"),
("bookwyrm", "0012_progressupdate"),
]
operations = [
]
operations = []

View file

@ -7,13 +7,18 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0014_auto_20201128_0118'),
("bookwyrm", "0014_auto_20201128_0118"),
]
operations = [
migrations.AlterField(
model_name='image',
name='status',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status'),
model_name="image",
name="status",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="bookwyrm.Status",
),
),
]

View file

@ -6,18 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0014_merge_20201128_0007'),
("bookwyrm", "0014_merge_20201128_0007"),
]
operations = [
migrations.RenameField(
model_name='readthrough',
old_name='pages_read',
new_name='progress',
model_name="readthrough",
old_name="pages_read",
new_name="progress",
),
migrations.AddField(
model_name='readthrough',
name='progress_mode',
field=models.CharField(choices=[('PG', 'page'), ('PCT', 'percent')], default='PG', max_length=3),
model_name="readthrough",
name="progress_mode",
field=models.CharField(
choices=[("PG", "page"), ("PCT", "percent")], default="PG", max_length=3
),
),
]

View file

@ -5,58 +5,101 @@ from django.db import migrations, models
import django.db.models.deletion
from django.contrib.postgres.fields import ArrayField
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0015_auto_20201128_0349'),
("bookwyrm", "0015_auto_20201128_0349"),
]
operations = [
migrations.AlterField(
model_name='book',
name='subject_places',
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
model_name="book",
name="subject_places",
field=ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
null=True,
size=None,
),
),
migrations.AlterField(
model_name='book',
name='subjects',
field=ArrayField(base_field=models.CharField(max_length=255), blank=True, default=list, null=True, size=None),
model_name="book",
name="subjects",
field=ArrayField(
base_field=models.CharField(max_length=255),
blank=True,
default=list,
null=True,
size=None,
),
),
migrations.AlterField(
model_name='edition',
name='parent_work',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='editions', to='bookwyrm.Work'),
model_name="edition",
name="parent_work",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="editions",
to="bookwyrm.Work",
),
),
migrations.AlterField(
model_name='tag',
name='name',
model_name="tag",
name="name",
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterUniqueTogether(
name='tag',
name="tag",
unique_together=set(),
),
migrations.RemoveField(
model_name='tag',
name='book',
model_name="tag",
name="book",
),
migrations.RemoveField(
model_name='tag',
name='user',
model_name="tag",
name="user",
),
migrations.CreateModel(
name='UserTag',
name="UserTag",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', models.CharField(max_length=255, null=True)),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Tag')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
("remote_id", models.CharField(max_length=255, null=True)),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Edition",
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Tag"
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'unique_together': {('user', 'book', 'tag')},
"unique_together": {("user", "book", "tag")},
},
),
]

View file

@ -6,23 +6,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0015_auto_20201128_0349'),
("bookwyrm", "0015_auto_20201128_0349"),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='admin_email',
model_name="sitesettings",
name="admin_email",
field=models.EmailField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='sitesettings',
name='support_link',
model_name="sitesettings",
name="support_link",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='sitesettings',
name='support_title',
model_name="sitesettings",
name="support_title",
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View file

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

View file

@ -7,13 +7,15 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0016_auto_20201211_2026'),
("bookwyrm", "0016_auto_20201211_2026"),
]
operations = [
migrations.AlterField(
model_name='readthrough',
name='book',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition'),
model_name="readthrough",
name="book",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.Edition"
),
),
]

View file

@ -6,20 +6,20 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0017_auto_20201130_1819'),
("bookwyrm", "0017_auto_20201130_1819"),
]
operations = [
migrations.RemoveField(
model_name='user',
name='following',
model_name="user",
name="following",
),
migrations.RemoveField(
model_name='user',
name='private_key',
model_name="user",
name="private_key",
),
migrations.RemoveField(
model_name='user',
name='public_key',
model_name="user",
name="public_key",
),
]

View file

@ -3,34 +3,36 @@
import bookwyrm.models.fields
from django.db import migrations
def update_notnull(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
users = app_registry.get_model('bookwyrm', 'User')
users = app_registry.get_model("bookwyrm", "User")
for user in users.objects.using(db_alias):
if user.name and user.summary:
continue
if not user.summary:
user.summary = ''
user.summary = ""
if not user.name:
user.name = ''
user.name = ""
user.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0018_auto_20201130_1832'),
("bookwyrm", "0018_auto_20201130_1832"),
]
operations = [
migrations.RunPython(update_notnull),
migrations.AlterField(
model_name='user',
name='name',
field=bookwyrm.models.fields.CharField(default='', max_length=100),
model_name="user",
name="name",
field=bookwyrm.models.fields.CharField(default="", max_length=100),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.TextField(default=''),
model_name="user",
name="summary",
field=bookwyrm.models.fields.TextField(default=""),
),
]

View file

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

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0020_auto_20201208_0213'),
('bookwyrm', '0016_auto_20201211_2026'),
("bookwyrm", "0020_auto_20201208_0213"),
("bookwyrm", "0016_auto_20201211_2026"),
]
operations = [
]
operations = []

View file

@ -5,26 +5,27 @@ from django.db import migrations
def set_author_name(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
authors = app_registry.get_model('bookwyrm', 'Author')
authors = app_registry.get_model("bookwyrm", "Author")
for author in authors.objects.using(db_alias):
if not author.name:
author.name = '%s %s' % (author.first_name, author.last_name)
author.name = "%s %s" % (author.first_name, author.last_name)
author.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0021_merge_20201212_1737'),
("bookwyrm", "0021_merge_20201212_1737"),
]
operations = [
migrations.RunPython(set_author_name),
migrations.RemoveField(
model_name='author',
name='first_name',
model_name="author",
name="first_name",
),
migrations.RemoveField(
model_name='author',
name='last_name',
model_name="author",
name="last_name",
),
]

View file

@ -7,13 +7,22 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0022_auto_20201212_1744'),
("bookwyrm", "0022_auto_20201212_1744"),
]
operations = [
migrations.AlterField(
model_name='status',
name='privacy',
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
model_name="status",
name="privacy",
field=bookwyrm.models.fields.PrivacyField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0017_auto_20201212_0059'),
('bookwyrm', '0022_auto_20201212_1744'),
("bookwyrm", "0017_auto_20201212_0059"),
("bookwyrm", "0022_auto_20201212_1744"),
]
operations = [
]
operations = []

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0023_auto_20201214_0511'),
('bookwyrm', '0023_merge_20201216_0112'),
("bookwyrm", "0023_auto_20201214_0511"),
("bookwyrm", "0023_merge_20201216_0112"),
]
operations = [
]
operations = []

View file

@ -7,33 +7,33 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0024_merge_20201216_1721'),
("bookwyrm", "0024_merge_20201216_1721"),
]
operations = [
migrations.AlterField(
model_name='author',
name='bio',
model_name="author",
name="bio",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='book',
name='description',
model_name="book",
name="description",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='quotation',
name='quote',
model_name="quotation",
name="quote",
field=bookwyrm.models.fields.HtmlField(),
),
migrations.AlterField(
model_name='status',
name='content',
model_name="status",
name="content",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
migrations.AlterField(
model_name='user',
name='summary',
field=bookwyrm.models.fields.HtmlField(default=''),
model_name="user",
name="summary",
field=bookwyrm.models.fields.HtmlField(default=""),
),
]

View file

@ -7,13 +7,15 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0025_auto_20201217_0046'),
("bookwyrm", "0025_auto_20201217_0046"),
]
operations = [
migrations.AddField(
model_name='status',
name='content_warning',
field=bookwyrm.models.fields.CharField(blank=True, max_length=500, null=True),
model_name="status",
name="content_warning",
field=bookwyrm.models.fields.CharField(
blank=True, max_length=500, null=True
),
),
]

View file

@ -7,18 +7,20 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0026_status_content_warning'),
("bookwyrm", "0026_status_content_warning"),
]
operations = [
migrations.AlterField(
model_name='user',
name='name',
field=bookwyrm.models.fields.CharField(blank=True, max_length=100, null=True),
model_name="user",
name="name",
field=bookwyrm.models.fields.CharField(
blank=True, max_length=100, null=True
),
),
migrations.AlterField(
model_name='user',
name='summary',
model_name="user",
name="summary",
field=bookwyrm.models.fields.HtmlField(blank=True, null=True),
),
]

View file

@ -6,12 +6,12 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0027_auto_20201220_2007'),
("bookwyrm", "0027_auto_20201220_2007"),
]
operations = [
migrations.RemoveField(
model_name='book',
name='author_text',
model_name="book",
name="author_text",
),
]

View file

@ -9,53 +9,65 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0028_remove_book_author_text'),
("bookwyrm", "0028_remove_book_author_text"),
]
operations = [
migrations.RemoveField(
model_name='author',
name='last_sync_date',
model_name="author",
name="last_sync_date",
),
migrations.RemoveField(
model_name='author',
name='sync',
model_name="author",
name="sync",
),
migrations.RemoveField(
model_name='book',
name='last_sync_date',
model_name="book",
name="last_sync_date",
),
migrations.RemoveField(
model_name='book',
name='sync',
model_name="book",
name="sync",
),
migrations.RemoveField(
model_name='book',
name='sync_cover',
model_name="book",
name="sync_cover",
),
migrations.AddField(
model_name='author',
name='goodreads_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
model_name="author",
name="goodreads_key",
field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
),
migrations.AddField(
model_name='author',
name='last_edited_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
model_name="author",
name="last_edited_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AddField(
model_name='author',
name='librarything_key',
field=bookwyrm.models.fields.CharField(blank=True, max_length=255, null=True),
model_name="author",
name="librarything_key",
field=bookwyrm.models.fields.CharField(
blank=True, max_length=255, null=True
),
),
migrations.AddField(
model_name='book',
name='last_edited_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
model_name="book",
name="last_edited_by",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name='author',
name='origin_id',
model_name="author",
name="origin_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -7,13 +7,18 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0029_auto_20201221_2014'),
("bookwyrm", "0029_auto_20201221_2014"),
]
operations = [
migrations.AlterField(
model_name='user',
name='localname',
field=models.CharField(max_length=255, null=True, unique=True, validators=[bookwyrm.models.fields.validate_localname]),
model_name="user",
name="localname",
field=models.CharField(
max_length=255,
null=True,
unique=True,
validators=[bookwyrm.models.fields.validate_localname],
),
),
]

View file

@ -6,23 +6,23 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0030_auto_20201224_1939'),
("bookwyrm", "0030_auto_20201224_1939"),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='favicon',
field=models.ImageField(blank=True, null=True, upload_to='logos/'),
model_name="sitesettings",
name="favicon",
field=models.ImageField(blank=True, null=True, upload_to="logos/"),
),
migrations.AddField(
model_name='sitesettings',
name='logo',
field=models.ImageField(blank=True, null=True, upload_to='logos/'),
model_name="sitesettings",
name="logo",
field=models.ImageField(blank=True, null=True, upload_to="logos/"),
),
migrations.AddField(
model_name='sitesettings',
name='logo_small',
field=models.ImageField(blank=True, null=True, upload_to='logos/'),
model_name="sitesettings",
name="logo_small",
field=models.ImageField(blank=True, null=True, upload_to="logos/"),
),
]

View file

@ -6,18 +6,20 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0031_auto_20210104_2040'),
("bookwyrm", "0031_auto_20210104_2040"),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='instance_tagline',
field=models.CharField(default='Social Reading and Reviewing', max_length=150),
model_name="sitesettings",
name="instance_tagline",
field=models.CharField(
default="Social Reading and Reviewing", max_length=150
),
),
migrations.AddField(
model_name='sitesettings',
name='registration_closed_text',
field=models.TextField(default='Contact an administrator to get an invite'),
model_name="sitesettings",
name="registration_closed_text",
field=models.TextField(default="Contact an administrator to get an invite"),
),
]

View file

@ -7,14 +7,16 @@ import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0032_auto_20210104_2055'),
("bookwyrm", "0032_auto_20210104_2055"),
]
operations = [
migrations.AddField(
model_name='siteinvite',
name='created_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
model_name="siteinvite",
name="created_date",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0033_siteinvite_created_date'),
("bookwyrm", "0033_siteinvite_created_date"),
]
operations = [
migrations.AddField(
model_name='importjob',
name='complete',
model_name="importjob",
name="complete",
field=models.BooleanField(default=False),
),
]

View file

@ -6,20 +6,21 @@ from django.db import migrations
def set_rank(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
books = app_registry.get_model('bookwyrm', 'Edition')
books = app_registry.get_model("bookwyrm", "Edition")
for book in books.objects.using(db_alias):
book.save()
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0034_importjob_complete'),
("bookwyrm", "0034_importjob_complete"),
]
operations = [
migrations.AddField(
model_name='edition',
name='edition_rank',
model_name="edition",
name="edition_rank",
field=bookwyrm.models.fields.IntegerField(default=0),
),
migrations.RunPython(set_rank),

View file

@ -9,24 +9,57 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0035_edition_edition_rank'),
("bookwyrm", "0035_edition_edition_rank"),
]
operations = [
migrations.CreateModel(
name='AnnualGoal',
name="AnnualGoal",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
('goal', models.IntegerField()),
('year', models.IntegerField(default=2021)),
('privacy', models.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("goal", models.IntegerField()),
("year", models.IntegerField(default=2021)),
(
"privacy",
models.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
'unique_together': {('user', 'year')},
"unique_together": {("user", "year")},
},
),
]

View file

@ -2,36 +2,39 @@
from django.db import migrations, models
def empty_to_null(apps, schema_editor):
User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email="").update(email=None)
def null_to_empty(apps, schema_editor):
User = apps.get_model("bookwyrm", "User")
db_alias = schema_editor.connection.alias
User.objects.using(db_alias).filter(email=None).update(email="")
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0036_annualgoal'),
("bookwyrm", "0036_annualgoal"),
]
operations = [
migrations.AlterModelOptions(
name='shelfbook',
options={'ordering': ('-created_date',)},
name="shelfbook",
options={"ordering": ("-created_date",)},
),
migrations.AlterField(
model_name='user',
name='email',
model_name="user",
name="email",
field=models.EmailField(max_length=254, null=True),
),
migrations.RunPython(empty_to_null, null_to_empty),
migrations.AlterField(
model_name='user',
name='email',
model_name="user",
name="email",
field=models.EmailField(max_length=254, null=True, unique=True),
),
]

View file

@ -7,13 +7,15 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0037_auto_20210118_1954'),
("bookwyrm", "0037_auto_20210118_1954"),
]
operations = [
migrations.AlterField(
model_name='annualgoal',
name='goal',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(1)]),
model_name="annualgoal",
name="goal",
field=models.IntegerField(
validators=[django.core.validators.MinValueValidator(1)]
),
),
]

View file

@ -6,9 +6,8 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0038_auto_20210119_1534'),
('bookwyrm', '0015_auto_20201128_0734'),
("bookwyrm", "0038_auto_20210119_1534"),
("bookwyrm", "0015_auto_20201128_0734"),
]
operations = [
]
operations = []

View file

@ -9,28 +9,40 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0039_merge_20210120_0753'),
("bookwyrm", "0039_merge_20210120_0753"),
]
operations = [
migrations.AlterField(
model_name='progressupdate',
name='progress',
field=models.IntegerField(validators=[django.core.validators.MinValueValidator(0)]),
model_name="progressupdate",
name="progress",
field=models.IntegerField(
validators=[django.core.validators.MinValueValidator(0)]
),
),
migrations.AlterField(
model_name='progressupdate',
name='readthrough',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ReadThrough'),
model_name="progressupdate",
name="readthrough",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.ReadThrough"
),
),
migrations.AlterField(
model_name='progressupdate',
name='remote_id',
field=bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id]),
model_name="progressupdate",
name="remote_id",
field=bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
migrations.AlterField(
model_name='readthrough',
name='progress',
field=models.IntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(0)]),
model_name="readthrough",
name="progress",
field=models.IntegerField(
blank=True,
null=True,
validators=[django.core.validators.MinValueValidator(0)],
),
),
]

View file

@ -10,56 +10,141 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0040_auto_20210122_0057'),
("bookwyrm", "0040_auto_20210122_0057"),
]
operations = [
migrations.CreateModel(
name='List',
name="List",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
('name', bookwyrm.models.fields.CharField(max_length=100)),
('description', bookwyrm.models.fields.TextField(blank=True, null=True)),
('privacy', bookwyrm.models.fields.CharField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255)),
('curation', bookwyrm.models.fields.CharField(choices=[('closed', 'Closed'), ('open', 'Open'), ('curated', 'Curated')], default='closed', max_length=255)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("name", bookwyrm.models.fields.CharField(max_length=100)),
(
"description",
bookwyrm.models.fields.TextField(blank=True, null=True),
),
(
"privacy",
bookwyrm.models.fields.CharField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
(
"curation",
bookwyrm.models.fields.CharField(
choices=[
("closed", "Closed"),
("open", "Open"),
("curated", "Curated"),
],
default="closed",
max_length=255,
),
),
],
options={
'abstract': False,
"abstract": False,
},
bases=(bookwyrm.models.activitypub_mixin.OrderedCollectionMixin, models.Model),
bases=(
bookwyrm.models.activitypub_mixin.OrderedCollectionMixin,
models.Model,
),
),
migrations.CreateModel(
name='ListItem',
name="ListItem",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', bookwyrm.models.fields.RemoteIdField(max_length=255, null=True, validators=[bookwyrm.models.fields.validate_remote_id])),
('notes', bookwyrm.models.fields.TextField(blank=True, null=True)),
('approved', models.BooleanField(default=True)),
('order', bookwyrm.models.fields.IntegerField(blank=True, null=True)),
('added_by', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('book', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='bookwyrm.Edition')),
('book_list', bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.List')),
('endorsement', models.ManyToManyField(related_name='endorsers', to=settings.AUTH_USER_MODEL)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("updated_date", models.DateTimeField(auto_now=True)),
(
"remote_id",
bookwyrm.models.fields.RemoteIdField(
max_length=255,
null=True,
validators=[bookwyrm.models.fields.validate_remote_id],
),
),
("notes", bookwyrm.models.fields.TextField(blank=True, null=True)),
("approved", models.BooleanField(default=True)),
("order", bookwyrm.models.fields.IntegerField(blank=True, null=True)),
(
"added_by",
bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
(
"book",
bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.Edition",
),
),
(
"book_list",
bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.List"
),
),
(
"endorsement",
models.ManyToManyField(
related_name="endorsers", to=settings.AUTH_USER_MODEL
),
),
],
options={
'ordering': ('-created_date',),
'unique_together': {('book', 'book_list')},
"ordering": ("-created_date",),
"unique_together": {("book", "book_list")},
},
bases=(bookwyrm.models.activitypub_mixin.ActivitypubMixin, models.Model),
),
migrations.AddField(
model_name='list',
name='books',
field=models.ManyToManyField(through='bookwyrm.ListItem', to='bookwyrm.Edition'),
model_name="list",
name="books",
field=models.ManyToManyField(
through="bookwyrm.ListItem", to="bookwyrm.Edition"
),
),
migrations.AddField(
model_name='list',
name='user',
field=bookwyrm.models.fields.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
model_name="list",
name="user",
field=bookwyrm.models.fields.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL
),
),
]

View file

@ -7,22 +7,40 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0041_auto_20210131_1614'),
("bookwyrm", "0041_auto_20210131_1614"),
]
operations = [
migrations.AlterModelOptions(
name='list',
options={'ordering': ('-updated_date',)},
name="list",
options={"ordering": ("-updated_date",)},
),
migrations.AlterField(
model_name='list',
name='privacy',
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
model_name="list",
name="privacy",
field=bookwyrm.models.fields.PrivacyField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
migrations.AlterField(
model_name='shelf',
name='privacy',
field=bookwyrm.models.fields.PrivacyField(choices=[('public', 'Public'), ('unlisted', 'Unlisted'), ('followers', 'Followers'), ('direct', 'Direct')], default='public', max_length=255),
model_name="shelf",
name="privacy",
field=bookwyrm.models.fields.PrivacyField(
choices=[
("public", "Public"),
("unlisted", "Unlisted"),
("followers", "Followers"),
("direct", "Direct"),
],
default="public",
max_length=255,
),
),
]

View file

@ -6,18 +6,18 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0042_auto_20210201_2108'),
("bookwyrm", "0042_auto_20210201_2108"),
]
operations = [
migrations.RenameField(
model_name='listitem',
old_name='added_by',
new_name='user',
model_name="listitem",
old_name="added_by",
new_name="user",
),
migrations.RenameField(
model_name='shelfbook',
old_name='added_by',
new_name='user',
model_name="shelfbook",
old_name="added_by",
new_name="user",
),
]

View file

@ -5,9 +5,10 @@ from django.conf import settings
from django.db import migrations
import django.db.models.deletion
def set_user(app_registry, schema_editor):
db_alias = schema_editor.connection.alias
shelfbook = app_registry.get_model('bookwyrm', 'ShelfBook')
shelfbook = app_registry.get_model("bookwyrm", "ShelfBook")
for item in shelfbook.objects.using(db_alias).filter(user__isnull=True):
item.user = item.shelf.user
try:
@ -19,15 +20,19 @@ def set_user(app_registry, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0043_auto_20210204_2223'),
("bookwyrm", "0043_auto_20210204_2223"),
]
operations = [
migrations.RunPython(set_user, lambda x, y: None),
migrations.AlterField(
model_name='shelfbook',
name='user',
field=bookwyrm.models.fields.ForeignKey(default=2, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
model_name="shelfbook",
name="user",
field=bookwyrm.models.fields.ForeignKey(
default=2,
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
preserve_default=False,
),
]

View file

@ -8,51 +8,102 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0044_auto_20210207_1924'),
("bookwyrm", "0044_auto_20210207_1924"),
]
operations = [
migrations.RemoveConstraint(
model_name='notification',
name='notification_type_valid',
model_name="notification",
name="notification_type_valid",
),
migrations.AddField(
model_name='notification',
name='related_list_item',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ListItem'),
model_name="notification",
name="related_list_item",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bookwyrm.ListItem",
),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('FAVORITE', 'Favorite'), ('REPLY', 'Reply'), ('MENTION', 'Mention'), ('TAG', 'Tag'), ('FOLLOW', 'Follow'), ('FOLLOW_REQUEST', 'Follow Request'), ('BOOST', 'Boost'), ('IMPORT', 'Import'), ('ADD', 'Add')], max_length=255),
model_name="notification",
name="notification_type",
field=models.CharField(
choices=[
("FAVORITE", "Favorite"),
("REPLY", "Reply"),
("MENTION", "Mention"),
("TAG", "Tag"),
("FOLLOW", "Follow"),
("FOLLOW_REQUEST", "Follow Request"),
("BOOST", "Boost"),
("IMPORT", "Import"),
("ADD", "Add"),
],
max_length=255,
),
),
migrations.AlterField(
model_name='notification',
name='related_book',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Edition'),
model_name="notification",
name="related_book",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bookwyrm.Edition",
),
),
migrations.AlterField(
model_name='notification',
name='related_import',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.ImportJob'),
model_name="notification",
name="related_import",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bookwyrm.ImportJob",
),
),
migrations.AlterField(
model_name='notification',
name='related_status',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='bookwyrm.Status'),
model_name="notification",
name="related_status",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bookwyrm.Status",
),
),
migrations.AlterField(
model_name='notification',
name='related_user',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='related_user', to=settings.AUTH_USER_MODEL),
model_name="notification",
name="related_user",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="related_user",
to=settings.AUTH_USER_MODEL,
),
),
migrations.AlterField(
model_name='notification',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
model_name="notification",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
migrations.AddConstraint(
model_name='notification',
constraint=models.CheckConstraint(check=models.Q(notification_type__in=['FAVORITE', 'REPLY', 'MENTION', 'TAG', 'FOLLOW', 'FOLLOW_REQUEST', 'BOOST', 'IMPORT', 'ADD']), name='notification_type_valid'),
model_name="notification",
constraint=models.CheckConstraint(
check=models.Q(
notification_type__in=[
"FAVORITE",
"REPLY",
"MENTION",
"TAG",
"FOLLOW",
"FOLLOW_REQUEST",
"BOOST",
"IMPORT",
"ADD",
]
),
name="notification_type_valid",
),
),
]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0045_auto_20210210_2114'),
("bookwyrm", "0045_auto_20210210_2114"),
]
operations = [
migrations.AddField(
model_name='sitesettings',
name='privacy_policy',
field=models.TextField(default='Add a privacy policy here.'),
model_name="sitesettings",
name="privacy_policy",
field=models.TextField(default="Add a privacy policy here."),
),
]

View file

@ -6,13 +6,13 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0046_sitesettings_privacy_policy'),
("bookwyrm", "0046_sitesettings_privacy_policy"),
]
operations = [
migrations.AddField(
model_name='connector',
name='isbn_search_url',
model_name="connector",
name="isbn_search_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View file

@ -1,4 +1,4 @@
''' bring all the models into the app namespace '''
""" bring all the models into the app namespace """
import inspect
import sys
@ -27,8 +27,12 @@ from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite, PasswordReset
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[1].activity_serializer.__name__: c[1] \
for c in cls_members if hasattr(c[1], 'activity_serializer')}
activity_models = {
c[1].activity_serializer.__name__: c[1]
for c in cls_members
if hasattr(c[1], "activity_serializer")
}
status_models = [
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)
]

View file

@ -1,4 +1,4 @@
''' activitypub model functionality '''
""" activitypub model functionality """
from base64 import b64encode
from functools import reduce
import json
@ -26,18 +26,19 @@ logger = logging.getLogger(__name__)
# I tried to separate these classes into mutliple files but I kept getting
# circular import errors so I gave up. I'm sure it could be done though!
class ActivitypubMixin:
''' add this mixin for models that are AP serializable '''
""" add this mixin for models that are AP serializable """
activity_serializer = lambda: {}
reverse_unfurl = False
def __init__(self, *args, **kwargs):
''' collect some info on model fields '''
""" collect some info on model fields """
self.image_fields = []
self.many_to_many_fields = []
self.simple_fields = [] # "simple"
# sort model fields by type
for field in self._meta.get_fields():
if not hasattr(field, 'field_to_activity'):
if not hasattr(field, "field_to_activity"):
continue
if isinstance(field, ImageField):
@ -48,33 +49,41 @@ class ActivitypubMixin:
self.simple_fields.append(field)
# a list of allll the serializable fields
self.activity_fields = self.image_fields + \
self.many_to_many_fields + self.simple_fields
self.activity_fields = (
self.image_fields + self.many_to_many_fields + self.simple_fields
)
# these are separate to avoid infinite recursion issues
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
if hasattr(self, 'deserialize_reverse_fields') else []
self.serialize_reverse_fields = self.serialize_reverse_fields \
if hasattr(self, 'serialize_reverse_fields') else []
self.deserialize_reverse_fields = (
self.deserialize_reverse_fields
if hasattr(self, "deserialize_reverse_fields")
else []
)
self.serialize_reverse_fields = (
self.serialize_reverse_fields
if hasattr(self, "serialize_reverse_fields")
else []
)
super().__init__(*args, **kwargs)
@classmethod
def find_existing_by_remote_id(cls, remote_id):
''' look up a remote id in the db '''
return cls.find_existing({'id': remote_id})
""" look up a remote id in the db """
return cls.find_existing({"id": remote_id})
@classmethod
def find_existing(cls, data):
''' compare data to fields that can be used for deduplation.
"""compare data to fields that can be used for deduplation.
This always includes remote_id, but can also be unique identifiers
like an isbn for an edition '''
like an isbn for an edition"""
filters = []
# grabs all the data from the model to create django queryset filters
for field in cls._meta.get_fields():
if not hasattr(field, 'deduplication_field') or \
not field.deduplication_field:
if (
not hasattr(field, "deduplication_field")
or not field.deduplication_field
):
continue
value = data.get(field.get_activitypub_field())
@ -82,9 +91,9 @@ class ActivitypubMixin:
continue
filters.append({field.name: value})
if hasattr(cls, 'origin_id') and 'id' in data:
if hasattr(cls, "origin_id") and "id" in data:
# kinda janky, but this handles special case for books
filters.append({'origin_id': data['id']})
filters.append({"origin_id": data["id"]})
if not filters:
# if there are no deduplication fields, it will match the first
@ -92,45 +101,41 @@ class ActivitypubMixin:
return None
objects = cls.objects
if hasattr(objects, 'select_subclasses'):
if hasattr(objects, "select_subclasses"):
objects = objects.select_subclasses()
# an OR operation on all the match fields, sorry for the dense syntax
match = objects.filter(
reduce(operator.or_, (Q(**f) for f in filters))
)
match = objects.filter(reduce(operator.or_, (Q(**f) for f in filters)))
# there OUGHT to be only one match
return match.first()
def broadcast(self, activity, sender, software=None):
''' send out an activity '''
""" send out an activity """
broadcast_task.delay(
sender.id,
json.dumps(activity, cls=activitypub.ActivityEncoder),
self.get_recipients(software=software)
self.get_recipients(software=software),
)
def get_recipients(self, software=None):
''' figure out which inbox urls to post to '''
""" figure out which inbox urls to post to """
# first we have to figure out who should receive this activity
privacy = self.privacy if hasattr(self, 'privacy') else 'public'
privacy = self.privacy if hasattr(self, "privacy") else "public"
# is this activity owned by a user (statuses, lists, shelves), or is it
# general to the instance (like books)
user = self.user if hasattr(self, 'user') else None
user_model = apps.get_model('bookwyrm.User', require_ready=True)
user = self.user if hasattr(self, "user") else None
user_model = apps.get_model("bookwyrm.User", require_ready=True)
if not user and isinstance(self, user_model):
# or maybe the thing itself is a user
user = self
# find anyone who's tagged in a status, for example
mentions = self.recipients if hasattr(self, 'recipients') else []
mentions = self.recipients if hasattr(self, "recipients") else []
# we always send activities to explicitly mentioned users' inboxes
recipients = [u.inbox for u in mentions or []]
# unless it's a dm, all the followers should receive the activity
if privacy != 'direct':
if privacy != "direct":
# we will send this out to a subset of all remote users
queryset = user_model.objects.filter(
local=False,
@ -138,43 +143,43 @@ class ActivitypubMixin:
# filter users first by whether they're using the desired software
# this lets us send book updates only to other bw servers
if software:
queryset = queryset.filter(
bookwyrm_user=(software == 'bookwyrm')
)
queryset = queryset.filter(bookwyrm_user=(software == "bookwyrm"))
# if there's a user, we only want to send to the user's followers
if user:
queryset = queryset.filter(following=user)
# ideally, we will send to shared inboxes for efficiency
shared_inboxes = queryset.filter(
shared_inbox__isnull=False
).values_list('shared_inbox', flat=True).distinct()
shared_inboxes = (
queryset.filter(shared_inbox__isnull=False)
.values_list("shared_inbox", flat=True)
.distinct()
)
# but not everyone has a shared inbox
inboxes = queryset.filter(
shared_inbox__isnull=True
).values_list('inbox', flat=True)
inboxes = queryset.filter(shared_inbox__isnull=True).values_list(
"inbox", flat=True
)
recipients += list(shared_inboxes) + list(inboxes)
return recipients
def to_activity_dataclass(self):
''' convert from a model to an activity '''
""" convert from a model to an activity """
activity = generate_activity(self)
return self.activity_serializer(**activity)
def to_activity(self, **kwargs): # pylint: disable=unused-argument
''' convert from a model to a json activity '''
""" convert from a model to a json activity """
return self.to_activity_dataclass().serialize()
class ObjectMixin(ActivitypubMixin):
''' add this mixin for object models that are AP serializable '''
""" add this mixin for object models that are AP serializable """
def save(self, *args, created=None, **kwargs):
''' broadcast created/updated/deleted objects as appropriate '''
broadcast = kwargs.get('broadcast', True)
""" broadcast created/updated/deleted objects as appropriate """
broadcast = kwargs.get("broadcast", True)
# this bonus kwarg woul cause an error in the base save method
if 'broadcast' in kwargs:
del kwargs['broadcast']
if "broadcast" in kwargs:
del kwargs["broadcast"]
created = created or not bool(self.id)
# first off, we want to save normally no matter what
@ -183,7 +188,7 @@ class ObjectMixin(ActivitypubMixin):
return
# this will work for objects owned by a user (lists, shelves)
user = self.user if hasattr(self, 'user') else None
user = self.user if hasattr(self, "user") else None
if created:
# broadcast Create activities for objects owned by a local user
@ -193,10 +198,10 @@ class ObjectMixin(ActivitypubMixin):
try:
software = None
# do we have a "pure" activitypub version of this for mastodon?
if hasattr(self, 'pure_content'):
if hasattr(self, "pure_content"):
pure_activity = self.to_create_activity(user, pure=True)
self.broadcast(pure_activity, user, software='other')
software = 'bookwyrm'
self.broadcast(pure_activity, user, software="other")
software = "bookwyrm"
# sends to BW only if we just did a pure version for masto
activity = self.to_create_activity(user)
self.broadcast(activity, user, software=software)
@ -209,39 +214,38 @@ class ObjectMixin(ActivitypubMixin):
# --- updating an existing object
if not user:
# users don't have associated users, they ARE users
user_model = apps.get_model('bookwyrm.User', require_ready=True)
user_model = apps.get_model("bookwyrm.User", require_ready=True)
if isinstance(self, user_model):
user = self
# book data tracks last editor
elif hasattr(self, 'last_edited_by'):
elif hasattr(self, "last_edited_by"):
user = self.last_edited_by
# again, if we don't know the user or they're remote, don't bother
if not user or not user.local:
return
# is this a deletion?
if hasattr(self, 'deleted') and self.deleted:
if hasattr(self, "deleted") and self.deleted:
activity = self.to_delete_activity(user)
else:
activity = self.to_update_activity(user)
self.broadcast(activity, user)
def to_create_activity(self, user, **kwargs):
''' returns the object wrapped in a Create activity '''
""" returns the object wrapped in a Create activity """
activity_object = self.to_activity_dataclass(**kwargs)
signature = None
create_id = self.remote_id + '/activity'
if hasattr(activity_object, 'content') and activity_object.content:
create_id = self.remote_id + "/activity"
if hasattr(activity_object, "content") and activity_object.content:
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
content = activity_object.content
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
signed_message = signer.sign(SHA256.new(content.encode("utf8")))
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
creator="%s#main-key" % user.remote_id,
created=activity_object.published,
signatureValue=b64encode(signed_message).decode('utf8')
signatureValue=b64encode(signed_message).decode("utf8"),
)
return activitypub.Create(
@ -253,50 +257,48 @@ class ObjectMixin(ActivitypubMixin):
signature=signature,
).serialize()
def to_delete_activity(self, user):
''' notice of deletion '''
""" notice of deletion """
return activitypub.Delete(
id=self.remote_id + '/activity',
id=self.remote_id + "/activity",
actor=user.remote_id,
to=['%s/followers' % user.remote_id],
cc=['https://www.w3.org/ns/activitystreams#Public'],
to=["%s/followers" % user.remote_id],
cc=["https://www.w3.org/ns/activitystreams#Public"],
object=self,
).serialize()
def to_update_activity(self, user):
""" wrapper for Updates to an activity """
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=["https://www.w3.org/ns/activitystreams#Public"],
object=self,
).serialize()
def to_update_activity(self, user):
''' wrapper for Updates to an activity '''
activity_id = '%s#update/%s' % (self.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
actor=user.remote_id,
to=['https://www.w3.org/ns/activitystreams#Public'],
object=self
).serialize()
class OrderedCollectionPageMixin(ObjectMixin):
''' just the paginator utilities, so you don't HAVE to
override ActivitypubMixin's to_activity (ie, for outbox) '''
"""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 '''
""" this can be overriden if there's a special remote id, ie outbox """
return self.remote_id
def to_ordered_collection(self, queryset, \
remote_id=None, page=False, collection_only=False, **kwargs):
''' an ordered collection of whatevers '''
def to_ordered_collection(
self, queryset, remote_id=None, page=False, collection_only=False, **kwargs
):
""" an ordered collection of whatevers """
if not queryset.ordered:
raise RuntimeError('queryset must be ordered')
raise RuntimeError("queryset must be ordered")
remote_id = remote_id or self.remote_id
if page:
return to_ordered_collection_page(
queryset, remote_id, **kwargs)
return to_ordered_collection_page(queryset, remote_id, **kwargs)
if collection_only or not hasattr(self, 'activity_serializer'):
if collection_only or not hasattr(self, "activity_serializer"):
serializer = activitypub.OrderedCollection
activity = {}
else:
@ -305,23 +307,24 @@ class OrderedCollectionPageMixin(ObjectMixin):
activity = generate_activity(self)
if remote_id:
activity['id'] = remote_id
activity["id"] = remote_id
paginated = Paginator(queryset, PAGE_LENGTH)
# add computed fields specific to orderd collections
activity['totalItems'] = paginated.count
activity['first'] = '%s?page=1' % remote_id
activity['last'] = '%s?page=%d' % (remote_id, paginated.num_pages)
activity["totalItems"] = paginated.count
activity["first"] = "%s?page=1" % remote_id
activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages)
return serializer(**activity)
class OrderedCollectionMixin(OrderedCollectionPageMixin):
''' extends activitypub models to work as ordered collections '''
""" 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')
""" usually an ordered collection model aggregates a different model """
raise NotImplementedError("Model must define collection_queryset")
activity_serializer = activitypub.OrderedCollection
@ -329,18 +332,20 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
return self.to_ordered_collection(self.collection_queryset, **kwargs)
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
""" an ordered collection of the specified model queryset """
return self.to_ordered_collection(
self.collection_queryset, **kwargs).serialize()
self.collection_queryset, **kwargs
).serialize()
class CollectionItemMixin(ActivitypubMixin):
''' for items that are part of an (Ordered)Collection '''
""" for items that are part of an (Ordered)Collection """
activity_serializer = activitypub.Add
object_field = collection_field = None
def save(self, *args, broadcast=True, **kwargs):
''' broadcast updated '''
""" broadcast updated """
created = not bool(self.id)
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
@ -353,89 +358,91 @@ class CollectionItemMixin(ActivitypubMixin):
activity = self.to_add_activity()
self.broadcast(activity, self.user)
def delete(self, *args, **kwargs):
''' broadcast a remove activity '''
""" broadcast a remove activity """
activity = self.to_remove_activity()
super().delete(*args, **kwargs)
self.broadcast(activity, self.user)
def to_add_activity(self):
''' AP for shelving a book'''
""" AP for shelving a book"""
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field)
return activitypub.Add(
id='%s#add' % self.remote_id,
id="%s#add" % self.remote_id,
actor=self.user.remote_id,
object=object_field,
target=collection_field.remote_id
target=collection_field.remote_id,
).serialize()
def to_remove_activity(self):
''' AP for un-shelving a book'''
""" AP for un-shelving a book"""
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field)
return activitypub.Remove(
id='%s#remove' % self.remote_id,
id="%s#remove" % self.remote_id,
actor=self.user.remote_id,
object=object_field,
target=collection_field.remote_id
target=collection_field.remote_id,
).serialize()
class ActivityMixin(ActivitypubMixin):
''' add this mixin for models that are AP serializable '''
""" add this mixin for models that are AP serializable """
def save(self, *args, broadcast=True, **kwargs):
''' broadcast activity '''
""" broadcast activity """
super().save(*args, **kwargs)
user = self.user if hasattr(self, 'user') else self.user_subject
user = self.user if hasattr(self, "user") else self.user_subject
if broadcast and user.local:
self.broadcast(self.to_activity(), user)
def delete(self, *args, broadcast=True, **kwargs):
''' nevermind, undo that activity '''
user = self.user if hasattr(self, 'user') else self.user_subject
""" nevermind, undo that activity """
user = self.user if hasattr(self, "user") else self.user_subject
if broadcast and user.local:
self.broadcast(self.to_undo_activity(), user)
super().delete(*args, **kwargs)
def to_undo_activity(self):
''' undo an action '''
user = self.user if hasattr(self, 'user') else self.user_subject
""" undo an action """
user = self.user if hasattr(self, "user") else self.user_subject
return activitypub.Undo(
id='%s#undo' % self.remote_id,
id="%s#undo" % self.remote_id,
actor=user.remote_id,
object=self,
).serialize()
def generate_activity(obj):
''' go through the fields on an object '''
""" go through the fields on an object """
activity = {}
for field in obj.activity_fields:
field.set_activity_from_field(activity, obj)
if hasattr(obj, 'serialize_reverse_fields'):
if hasattr(obj, "serialize_reverse_fields"):
# for example, editions of a work
for model_field_name, activity_field_name, sort_field in \
obj.serialize_reverse_fields:
for (
model_field_name,
activity_field_name,
sort_field,
) in obj.serialize_reverse_fields:
related_field = getattr(obj, model_field_name)
activity[activity_field_name] = \
unfurl_related_field(related_field, sort_field)
activity[activity_field_name] = unfurl_related_field(
related_field, sort_field
)
if not activity.get('id'):
activity['id'] = obj.get_remote_id()
if not activity.get("id"):
activity["id"] = obj.get_remote_id()
return activity
def unfurl_related_field(related_field, sort_field=None):
''' load reverse lookups (like public key owner or Status attachment '''
if hasattr(related_field, 'all'):
return [unfurl_related_field(i) for i in related_field.order_by(
sort_field).all()]
""" load reverse lookups (like public key owner or Status attachment """
if hasattr(related_field, "all"):
return [
unfurl_related_field(i) for i in related_field.order_by(sort_field).all()
]
if related_field.reverse_unfurl:
return related_field.field_to_activity()
return related_field.remote_id
@ -443,8 +450,8 @@ def unfurl_related_field(related_field, sort_field=None):
@app.task
def broadcast_task(sender_id, activity, recipients):
''' the celery task for broadcast '''
user_model = apps.get_model('bookwyrm.User', require_ready=True)
""" the celery task for broadcast """
user_model = apps.get_model("bookwyrm.User", require_ready=True)
sender = user_model.objects.get(id=sender_id)
for recipient in recipients:
try:
@ -454,12 +461,12 @@ def broadcast_task(sender_id, activity, recipients):
def sign_and_send(sender, data, destination):
''' crpyto whatever and http junk '''
""" crpyto whatever and http junk """
now = http_date()
if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened.
raise ValueError('No private key found for sender')
raise ValueError("No private key found for sender")
digest = make_digest(data)
@ -467,11 +474,11 @@ def sign_and_send(sender, data, destination):
destination,
data=data,
headers={
'Date': now,
'Digest': digest,
'Signature': make_signature(sender, destination, now, digest),
'Content-Type': 'application/activity+json; charset=utf-8',
'User-Agent': USER_AGENT,
"Date": now,
"Digest": digest,
"Signature": make_signature(sender, destination, now, digest),
"Content-Type": "application/activity+json; charset=utf-8",
"User-Agent": USER_AGENT,
},
)
if not response.ok:
@ -481,8 +488,9 @@ def sign_and_send(sender, data, destination):
# pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs):
''' serialize and pagiante a queryset '''
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
):
""" serialize and pagiante a queryset """
paginated = Paginator(queryset, PAGE_LENGTH)
activity_page = paginated.page(page)
@ -493,14 +501,13 @@ def to_ordered_collection_page(
prev_page = next_page = None
if activity_page.has_next():
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number())
if activity_page.has_previous():
prev_page = '%s?page=%d' % \
(remote_id, activity_page.previous_page_number())
prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number())
return activitypub.OrderedCollectionPage(
id='%s?page=%s' % (remote_id, page),
id="%s?page=%s" % (remote_id, page),
partOf=remote_id,
orderedItems=items,
next=next_page,
prev=prev_page
prev=prev_page,
)

View file

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

View file

@ -1,4 +1,4 @@
''' database schema for info about authors '''
""" database schema for info about authors """
from django.db import models
from bookwyrm import activitypub
@ -9,9 +9,11 @@ from . import fields
class Author(BookDataModel):
''' basic biographic info '''
""" basic biographic info """
wikipedia_link = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
# idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)
@ -22,7 +24,7 @@ class Author(BookDataModel):
bio = fields.HtmlField(null=True, blank=True)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
return 'https://%s/author/%s' % (DOMAIN, self.id)
""" editions and works both use "book" instead of model_name """
return "https://%s/author/%s" % (DOMAIN, self.id)
activity_serializer = activitypub.Author

View file

@ -1,4 +1,4 @@
''' base model with default fields '''
""" base model with default fields """
from django.db import models
from django.dispatch import receiver
@ -7,34 +7,36 @@ from .fields import RemoteIdField
class BookWyrmModel(models.Model):
''' shared fields '''
""" shared fields """
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
remote_id = RemoteIdField(null=True, activitypub_field='id')
remote_id = RemoteIdField(null=True, activitypub_field="id")
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 = '%s%s' % (base_path, self.user.local_path)
""" generate a url that resolves to the local object """
base_path = "https://%s" % DOMAIN
if hasattr(self, "user"):
base_path = "%s%s" % (base_path, self.user.local_path)
model_name = type(self).__name__.lower()
return '%s/%s/%d' % (base_path, model_name, self.id)
return "%s/%s/%d" % (base_path, model_name, self.id)
class Meta:
''' this is just here to provide default fields for other models '''
""" this is just here to provide default fields for other models """
abstract = True
@property
def local_path(self):
''' how to link to this object in the local app '''
return self.get_remote_id().replace('https://%s' % DOMAIN, '')
""" how to link to this object in the local app """
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
@receiver(models.signals.post_save)
#pylint: disable=unused-argument
# pylint: disable=unused-argument
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'):
""" 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()

View file

@ -1,4 +1,4 @@
''' database schema for books and shelves '''
""" database schema for books and shelves """
import re
from django.db import models
@ -11,25 +11,30 @@ from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
from . import fields
class BookDataModel(ObjectMixin, BookWyrmModel):
''' fields shared between editable book data (books, works, authors) '''
""" fields shared between editable book data (books, works, authors) """
origin_id = models.CharField(max_length=255, null=True, blank=True)
openlibrary_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
librarything_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
goodreads_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
last_edited_by = models.ForeignKey(
'User', on_delete=models.PROTECT, null=True)
last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True)
class Meta:
''' can't initialize this model, that wouldn't make sense '''
""" can't initialize this model, that wouldn't make sense """
abstract = True
def save(self, *args, **kwargs):
''' ensure that the remote_id is within this instance '''
""" ensure that the remote_id is within this instance """
if self.id:
self.remote_id = self.get_remote_id()
else:
@ -37,15 +42,15 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
self.remote_id = None
return super().save(*args, **kwargs)
def broadcast(self, activity, sender, software='bookwyrm'):
''' only send book data updates to other bookwyrm instances '''
def broadcast(self, activity, sender, software="bookwyrm"):
""" only send book data updates to other bookwyrm instances """
super().broadcast(activity, sender, software=software)
class Book(BookDataModel):
''' a generic book, which can mean either an edition or a work '''
connector = models.ForeignKey(
'Connector', on_delete=models.PROTECT, null=True)
""" a generic book, which can mean either an edition or a work """
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
# book/work metadata
title = fields.CharField(max_length=255)
@ -63,9 +68,10 @@ class Book(BookDataModel):
subject_places = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
)
authors = fields.ManyToManyField('Author')
authors = fields.ManyToManyField("Author")
cover = fields.ImageField(
upload_to='covers/', blank=True, null=True, alt_field='alt_text')
upload_to="covers/", blank=True, null=True, alt_field="alt_text"
)
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
@ -73,42 +79,43 @@ class Book(BookDataModel):
@property
def author_text(self):
''' format a list of authors '''
return ', '.join(a.name for a in self.authors.all())
""" format a list of authors """
return ", ".join(a.name for a in self.authors.all())
@property
def latest_readthrough(self):
''' most recent readthrough activity '''
return self.readthrough_set.order_by('-updated_date').first()
""" most recent readthrough activity """
return self.readthrough_set.order_by("-updated_date").first()
@property
def edition_info(self):
''' properties of this edition, as a string '''
""" properties of this edition, as a string """
items = [
self.physical_format if hasattr(self, 'physical_format') else None,
self.languages[0] + ' language' if self.languages and \
self.languages[0] != 'English' else None,
self.physical_format if hasattr(self, "physical_format") else None,
self.languages[0] + " language"
if self.languages and self.languages[0] != "English"
else None,
str(self.published_date.year) if self.published_date else None,
]
return ', '.join(i for i in items if i)
return ", ".join(i for i in items if i)
@property
def alt_text(self):
''' image alt test '''
text = '%s' % self.title
""" image alt test """
text = "%s" % self.title
if self.edition_info:
text += ' (%s)' % self.edition_info
text += " (%s)" % self.edition_info
return text
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
""" can't be abstract for query reasons, but you shouldn't USE it """
if not isinstance(self, Edition) and not isinstance(self, Work):
raise ValueError('Books should be added as Editions or Works')
raise ValueError("Books should be added as Editions or Works")
return super().save(*args, **kwargs)
def get_remote_id(self):
''' editions and works both use "book" instead of model_name '''
return 'https://%s/book/%d' % (DOMAIN, self.id)
""" editions and works both use "book" instead of model_name """
return "https://%s/book/%d" % (DOMAIN, self.id)
def __repr__(self):
return "<{} key={!r} title={!r}>".format(
@ -119,76 +126,82 @@ class Book(BookDataModel):
class Work(OrderedCollectionPageMixin, Book):
''' a work (an abstract concept of a book that manifests in an edition) '''
""" a work (an abstract concept of a book that manifests in an edition) """
# library of congress catalog control number
lccn = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
# this has to be nullable but should never be null
default_edition = fields.ForeignKey(
'Edition',
on_delete=models.PROTECT,
null=True,
load_remote=False
"Edition", on_delete=models.PROTECT, null=True, load_remote=False
)
def save(self, *args, **kwargs):
''' set some fields on the edition object '''
""" set some fields on the edition object """
# set rank
for edition in self.editions.all():
edition.save()
return super().save(*args, **kwargs)
def get_default_edition(self):
''' in case the default edition is not set '''
return self.default_edition or self.editions.order_by(
'-edition_rank'
).first()
""" in case the default edition is not set """
return self.default_edition or self.editions.order_by("-edition_rank").first()
def to_edition_list(self, **kwargs):
''' an ordered collection of editions '''
""" an ordered collection of editions """
return self.to_ordered_collection(
self.editions.order_by('-edition_rank').all(),
remote_id='%s/editions' % self.remote_id,
self.editions.order_by("-edition_rank").all(),
remote_id="%s/editions" % self.remote_id,
**kwargs
)
activity_serializer = activitypub.Work
serialize_reverse_fields = [('editions', 'editions', '-edition_rank')]
deserialize_reverse_fields = [('editions', 'editions')]
serialize_reverse_fields = [("editions", "editions", "-edition_rank")]
deserialize_reverse_fields = [("editions", "editions")]
class Edition(Book):
''' an edition of a book '''
""" an edition of a book """
# these identifiers only apply to editions, not works
isbn_10 = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
isbn_13 = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
oclc_number = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
asin = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
max_length=255, blank=True, null=True, deduplication_field=True
)
pages = fields.IntegerField(blank=True, null=True)
physical_format = fields.CharField(max_length=255, blank=True, null=True)
publishers = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
shelves = models.ManyToManyField(
'Shelf',
"Shelf",
symmetrical=False,
through='ShelfBook',
through_fields=('book', 'shelf')
through="ShelfBook",
through_fields=("book", "shelf"),
)
parent_work = fields.ForeignKey(
'Work', on_delete=models.PROTECT, null=True,
related_name='editions', activitypub_field='work')
"Work",
on_delete=models.PROTECT,
null=True,
related_name="editions",
activitypub_field="work",
)
edition_rank = fields.IntegerField(default=0)
activity_serializer = activitypub.Edition
name_field = 'title'
name_field = "title"
def get_rank(self):
''' calculate how complete the data is on this edition '''
""" calculate how complete the data is on this edition """
if self.parent_work and self.parent_work.default_edition == self:
# default edition has the highest rank
return 20
@ -204,9 +217,9 @@ class Edition(Book):
return rank
def save(self, *args, **kwargs):
''' set some fields on the edition object '''
""" set some fields on the edition object """
# calculate isbn 10/13
if self.isbn_13 and self.isbn_13[:3] == '978' and not self.isbn_10:
if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10:
self.isbn_10 = isbn_13_to_10(self.isbn_13)
if self.isbn_10 and not self.isbn_13:
self.isbn_13 = isbn_10_to_13(self.isbn_10)
@ -218,17 +231,18 @@ class Edition(Book):
def isbn_10_to_13(isbn_10):
''' convert an isbn 10 into an isbn 13 '''
isbn_10 = re.sub(r'[^0-9X]', '', isbn_10)
""" convert an isbn 10 into an isbn 13 """
isbn_10 = re.sub(r"[^0-9X]", "", isbn_10)
# drop the last character of the isbn 10 number (the original checkdigit)
converted = isbn_10[:9]
# add "978" to the front
converted = '978' + converted
converted = "978" + converted
# add a check digit to the end
# multiply the odd digits by 1 and the even digits by 3 and sum them
try:
checksum = sum(int(i) for i in converted[::2]) + \
sum(int(i) * 3 for i in converted[1::2])
checksum = sum(int(i) for i in converted[::2]) + sum(
int(i) * 3 for i in converted[1::2]
)
except ValueError:
return None
# add the checksum mod 10 to the end
@ -239,11 +253,11 @@ def isbn_10_to_13(isbn_10):
def isbn_13_to_10(isbn_13):
''' convert isbn 13 to 10, if possible '''
if isbn_13[:3] != '978':
""" convert isbn 13 to 10, if possible """
if isbn_13[:3] != "978":
return None
isbn_13 = re.sub(r'[^0-9X]', '', isbn_13)
isbn_13 = re.sub(r"[^0-9X]", "", isbn_13)
# remove '978' and old checkdigit
converted = isbn_13[3:-1]
@ -256,5 +270,5 @@ def isbn_13_to_10(isbn_13):
checkdigit = checksum % 11
checkdigit = 11 - checkdigit
if checkdigit == 10:
checkdigit = 'X'
checkdigit = "X"
return converted + str(checkdigit)

View file

@ -1,21 +1,21 @@
''' manages interfaces with external sources of book data '''
""" manages interfaces with external sources of book data """
from django.db import models
from bookwyrm.connectors.settings import CONNECTORS
from .base_model import BookWyrmModel
ConnectorFiles = models.TextChoices('ConnectorFiles', CONNECTORS)
ConnectorFiles = models.TextChoices("ConnectorFiles", CONNECTORS)
class Connector(BookWyrmModel):
''' book data source connectors '''
""" 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, blank=True)
local = models.BooleanField(default=False)
connector_file = models.CharField(
max_length=255,
choices=ConnectorFiles.choices
)
connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)
api_key = models.CharField(max_length=255, null=True, blank=True)
base_url = models.CharField(max_length=255)
@ -24,7 +24,7 @@ class Connector(BookWyrmModel):
search_url = models.CharField(max_length=255, null=True, blank=True)
isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
politeness_delay = models.IntegerField(null=True, blank=True) #seconds
politeness_delay = models.IntegerField(null=True, blank=True) # seconds
max_query_count = models.IntegerField(null=True, blank=True)
# how many queries executed in a unit of time, like a day
query_count = models.IntegerField(default=0)
@ -32,11 +32,12 @@ class Connector(BookWyrmModel):
query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True)
class Meta:
''' check that there's code to actually use this connector '''
""" check that there's code to actually use this connector """
constraints = [
models.CheckConstraint(
check=models.Q(connector_file__in=ConnectorFiles),
name='connector_file_valid'
name="connector_file_valid",
)
]

View file

@ -1,4 +1,4 @@
''' like/fav/star a status '''
""" like/fav/star a status """
from django.apps import apps
from django.db import models
from django.utils import timezone
@ -9,50 +9,59 @@ from .base_model import BookWyrmModel
from . import fields
from .status import Status
class Favorite(ActivityMixin, BookWyrmModel):
''' fav'ing a post '''
""" fav'ing a post """
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
status = fields.ForeignKey(
'Status', on_delete=models.PROTECT, activitypub_field='object')
"Status", on_delete=models.PROTECT, activitypub_field="object"
)
activity_serializer = activitypub.Like
@classmethod
def ignore_activity(cls, activity):
''' don't bother with incoming favs of unknown statuses '''
""" don't bother with incoming favs of unknown statuses """
return not Status.objects.filter(remote_id=activity.object).exists()
def save(self, *args, **kwargs):
''' update user active time '''
""" update user active time """
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
if self.status.user.local and self.status.user != self.user:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
"bookwyrm.Notification", require_ready=True
)
notification_model.objects.create(
user=self.status.user,
notification_type='FAVORITE',
notification_type="FAVORITE",
related_user=self.user,
related_status=self.status
related_status=self.status,
)
def delete(self, *args, **kwargs):
''' delete and delete notifications '''
""" delete and delete notifications """
# check for notification
if self.status.user.local:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
"bookwyrm.Notification", require_ready=True
)
notification = notification_model.objects.filter(
user=self.status.user, related_user=self.user,
related_status=self.status, notification_type='FAVORITE'
user=self.status.user,
related_user=self.user,
related_status=self.status,
notification_type="FAVORITE",
).first()
if notification:
notification.delete()
super().delete(*args, **kwargs)
class Meta:
''' can't fav things twice '''
unique_together = ('user', 'status')
""" can't fav things twice """
unique_together = ("user", "status")

View file

@ -1,15 +1,17 @@
''' connections to external ActivityPub servers '''
""" connections to external ActivityPub servers """
from django.db import models
from .base_model import BookWyrmModel
class FederatedServer(BookWyrmModel):
''' store which server's we federate with '''
""" 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')
status = models.CharField(max_length=255, default="federated")
# is it mastodon, bookwyrm, etc
application_type = models.CharField(max_length=255, null=True)
application_version = models.CharField(max_length=255, null=True)
# TODO: blocked servers

View file

@ -1,4 +1,4 @@
''' activitypub-aware django model fields '''
""" activitypub-aware django model fields """
from dataclasses import MISSING
import re
from uuid import uuid4
@ -18,37 +18,43 @@ from bookwyrm.settings import DOMAIN
def validate_remote_id(value):
''' make sure the remote_id looks like a url '''
if not value or not re.match(r'^http.?:\/\/[^\s]+$', value):
""" make sure the remote_id looks like a url """
if not value or not re.match(r"^http.?:\/\/[^\s]+$", value):
raise ValidationError(
_('%(value)s is not a valid remote_id'),
params={'value': value},
_("%(value)s is not a valid remote_id"),
params={"value": value},
)
def validate_localname(value):
''' make sure localnames look okay '''
if not re.match(r'^[A-Za-z\-_\.0-9]+$', value):
""" make sure localnames look okay """
if not re.match(r"^[A-Za-z\-_\.0-9]+$", value):
raise ValidationError(
_('%(value)s is not a valid username'),
params={'value': value},
_("%(value)s is not a valid username"),
params={"value": value},
)
def validate_username(value):
''' make sure usernames look okay '''
if not re.match(r'^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$', value):
""" make sure usernames look okay """
if not re.match(r"^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$", value):
raise ValidationError(
_('%(value)s is not a valid username'),
params={'value': value},
_("%(value)s is not a valid username"),
params={"value": value},
)
class ActivitypubFieldMixin:
''' make a database field serializable '''
def __init__(self, *args, \
activitypub_field=None, activitypub_wrapper=None,
deduplication_field=False, **kwargs):
""" make a database field serializable """
def __init__(
self,
*args,
activitypub_field=None,
activitypub_wrapper=None,
deduplication_field=False,
**kwargs
):
self.deduplication_field = deduplication_field
if activitypub_wrapper:
self.activitypub_wrapper = activitypub_field
@ -57,24 +63,22 @@ class ActivitypubFieldMixin:
self.activitypub_field = activitypub_field
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
''' helper function for assinging a value to the field '''
""" helper function for assinging a value to the field """
try:
value = getattr(data, self.get_activitypub_field())
except AttributeError:
# masssively hack-y workaround for boosts
if self.get_activitypub_field() != 'attributedTo':
if self.get_activitypub_field() != "attributedTo":
raise
value = getattr(data, 'actor')
value = getattr(data, "actor")
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
return
setattr(instance, self.name, formatted)
def set_activity_from_field(self, activity, instance):
''' update the json object '''
""" update the json object """
value = getattr(instance, self.name)
formatted = self.field_to_activity(value)
if formatted is None:
@ -82,37 +86,37 @@ class ActivitypubFieldMixin:
key = self.get_activitypub_field()
# TODO: surely there's a better way
if instance.__class__.__name__ == 'Boost' and key == 'attributedTo':
key = 'actor'
if instance.__class__.__name__ == "Boost" and key == "attributedTo":
key = "actor"
if isinstance(activity.get(key), list):
activity[key] += formatted
else:
activity[key] = formatted
def field_to_activity(self, value):
''' formatter to convert a model value into activitypub '''
if hasattr(self, 'activitypub_wrapper'):
""" formatter to convert a model value into activitypub """
if hasattr(self, "activitypub_wrapper"):
return {self.activitypub_wrapper: value}
return value
def field_from_activity(self, value):
''' formatter to convert activitypub into a model value '''
if hasattr(self, 'activitypub_wrapper'):
""" formatter to convert activitypub into a model value """
if hasattr(self, "activitypub_wrapper"):
value = value.get(self.activitypub_wrapper)
return value
def get_activitypub_field(self):
''' model_field_name to activitypubFieldName '''
""" model_field_name to activitypubFieldName """
if self.activitypub_field:
return self.activitypub_field
name = self.name.split('.')[-1]
components = name.split('_')
return components[0] + ''.join(x.title() for x in components[1:])
name = self.name.split(".")[-1]
components = name.split("_")
return components[0] + "".join(x.title() for x in components[1:])
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
''' default (de)serialization for foreign key and one to one '''
""" default (de)serialization for foreign key and one to one """
def __init__(self, *args, load_remote=True, **kwargs):
self.load_remote = load_remote
super().__init__(*args, **kwargs)
@ -122,7 +126,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
return None
related_model = self.related_model
if hasattr(value, 'id') and value.id:
if hasattr(value, "id") and value.id:
if not self.load_remote:
# only look in the local database
return related_model.find_existing(value.serialize())
@ -142,99 +146,98 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
''' a url that serves as a unique identifier '''
""" a url that serves as a unique identifier """
def __init__(self, *args, max_length=255, validators=None, **kwargs):
validators = validators or [validate_remote_id]
super().__init__(
*args, max_length=max_length, validators=validators,
**kwargs
)
super().__init__(*args, max_length=max_length, validators=validators, **kwargs)
# for this field, the default is true. false everywhere else.
self.deduplication_field = kwargs.get('deduplication_field', True)
self.deduplication_field = kwargs.get("deduplication_field", True)
class UsernameField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware username field '''
def __init__(self, activitypub_field='preferredUsername', **kwargs):
""" activitypub-aware username field """
def __init__(self, activitypub_field="preferredUsername", **kwargs):
self.activitypub_field = activitypub_field
# I don't totally know why pylint is mad at this, but it makes it work
super( #pylint: disable=bad-super-call
ActivitypubFieldMixin, self
).__init__(
_('username'),
super(ActivitypubFieldMixin, self).__init__( # pylint: disable=bad-super-call
_("username"),
max_length=150,
unique=True,
validators=[validate_username],
error_messages={
'unique': _('A user with that username already exists.'),
"unique": _("A user with that username already exists."),
},
)
def deconstruct(self):
''' implementation of models.Field deconstruct '''
""" implementation of models.Field deconstruct """
name, path, args, kwargs = super().deconstruct()
del kwargs['verbose_name']
del kwargs['max_length']
del kwargs['unique']
del kwargs['validators']
del kwargs['error_messages']
del kwargs["verbose_name"]
del kwargs["max_length"]
del kwargs["unique"]
del kwargs["validators"]
del kwargs["error_messages"]
return name, path, args, kwargs
def field_to_activity(self, value):
return value.split('@')[0]
return value.split("@")[0]
PrivacyLevels = models.TextChoices('Privacy', [
'public',
'unlisted',
'followers',
'direct'
])
PrivacyLevels = models.TextChoices(
"Privacy", ["public", "unlisted", "followers", "direct"]
)
class PrivacyField(ActivitypubFieldMixin, models.CharField):
''' this maps to two differente activitypub fields '''
public = 'https://www.w3.org/ns/activitystreams#Public'
""" this maps to two differente activitypub fields """
public = "https://www.w3.org/ns/activitystreams#Public"
def __init__(self, *args, **kwargs):
super().__init__(
*args, max_length=255,
choices=PrivacyLevels.choices, default='public')
*args, max_length=255, choices=PrivacyLevels.choices, default="public"
)
def set_field_from_activity(self, instance, data):
to = data.to
cc = data.cc
if to == [self.public]:
setattr(instance, self.name, 'public')
setattr(instance, self.name, "public")
elif cc == []:
setattr(instance, self.name, 'direct')
setattr(instance, self.name, "direct")
elif self.public in cc:
setattr(instance, self.name, 'unlisted')
setattr(instance, self.name, "unlisted")
else:
setattr(instance, self.name, 'followers')
setattr(instance, self.name, "followers")
def set_activity_from_field(self, activity, instance):
# explicitly to anyone mentioned (statuses only)
mentions = []
if hasattr(instance, 'mention_users'):
if hasattr(instance, "mention_users"):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
followers = instance.user.__class__._meta.get_field('followers')\
.field_to_activity(instance.user.followers)
if instance.privacy == 'public':
activity['to'] = [self.public]
activity['cc'] = [followers] + mentions
elif instance.privacy == 'unlisted':
activity['to'] = [followers]
activity['cc'] = [self.public] + mentions
elif instance.privacy == 'followers':
activity['to'] = [followers]
activity['cc'] = mentions
if instance.privacy == 'direct':
activity['to'] = mentions
activity['cc'] = []
followers = instance.user.__class__._meta.get_field(
"followers"
).field_to_activity(instance.user.followers)
if instance.privacy == "public":
activity["to"] = [self.public]
activity["cc"] = [followers] + mentions
elif instance.privacy == "unlisted":
activity["to"] = [followers]
activity["cc"] = [self.public] + mentions
elif instance.privacy == "followers":
activity["to"] = [followers]
activity["cc"] = mentions
if instance.privacy == "direct":
activity["to"] = mentions
activity["cc"] = []
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
''' activitypub-aware foreign key field '''
""" activitypub-aware foreign key field """
def field_to_activity(self, value):
if not value:
return None
@ -242,7 +245,8 @@ class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
''' activitypub-aware foreign key field '''
""" activitypub-aware foreign key field """
def field_to_activity(self, value):
if not value:
return None
@ -250,13 +254,14 @@ class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
''' activitypub-aware many to many field '''
""" activitypub-aware many to many field """
def __init__(self, *args, link_only=False, **kwargs):
self.link_only = link_only
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
''' helper function for assinging a value to the field '''
""" helper function for assinging a value to the field """
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
@ -266,7 +271,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
def field_to_activity(self, value):
if self.link_only:
return '%s/%s' % (value.instance.remote_id, self.name)
return "%s/%s" % (value.instance.remote_id, self.name)
return [i.remote_id for i in value.all()]
def field_from_activity(self, value):
@ -279,29 +284,31 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
except ValidationError:
continue
items.append(
activitypub.resolve_remote_id(
remote_id, model=self.related_model)
activitypub.resolve_remote_id(remote_id, model=self.related_model)
)
return items
class TagField(ManyToManyField):
''' special case of many to many that uses Tags '''
""" special case of many to many that uses Tags """
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.activitypub_field = 'tag'
self.activitypub_field = "tag"
def field_to_activity(self, value):
tags = []
for item in value.all():
activity_type = item.__class__.__name__
if activity_type == 'User':
activity_type = 'Mention'
tags.append(activitypub.Link(
if activity_type == "User":
activity_type = "Mention"
tags.append(
activitypub.Link(
href=item.remote_id,
name=getattr(item, item.name_field),
type=activity_type
))
type=activity_type,
)
)
return tags
def field_from_activity(self, value):
@ -310,38 +317,38 @@ class TagField(ManyToManyField):
items = []
for link_json in value:
link = activitypub.Link(**link_json)
tag_type = link.type if link.type != 'Mention' else 'Person'
if tag_type == 'Book':
tag_type = 'Edition'
tag_type = link.type if link.type != "Mention" else "Person"
if tag_type == "Book":
tag_type = "Edition"
if tag_type != self.related_model.activity_serializer.type:
# tags can contain multiple types
continue
items.append(
activitypub.resolve_remote_id(
link.href, model=self.related_model)
activitypub.resolve_remote_id(link.href, model=self.related_model)
)
return items
def image_serializer(value, alt):
''' helper for serializing images '''
if value and hasattr(value, 'url'):
""" helper for serializing images """
if value and hasattr(value, "url"):
url = value.url
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
url = "https://%s%s" % (DOMAIN, url)
return activitypub.Image(url=url, name=alt)
class ImageField(ActivitypubFieldMixin, models.ImageField):
''' activitypub-aware image field '''
""" activitypub-aware image field """
def __init__(self, *args, alt_field=None, **kwargs):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ
def set_field_from_activity(self, instance, data, save=True):
''' helper function for assinging a value to the field '''
""" helper function for assinging a value to the field """
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
@ -358,16 +365,14 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
key = self.get_activitypub_field()
activity[key] = formatted
def field_to_activity(self, value, alt=None):
return image_serializer(value, alt)
def field_from_activity(self, value):
image_slug = value
# when it's an inline image (User avatar/icon, Book cover), it's a json
# blob, but when it's an attached image, it's just a url
if hasattr(image_slug, 'url'):
if hasattr(image_slug, "url"):
url = image_slug.url
elif isinstance(image_slug, str):
url = image_slug
@ -383,13 +388,14 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if not response:
return None
image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_name = str(uuid4()) + "." + url.split(".")[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
''' activitypub-aware datetime field '''
""" activitypub-aware datetime field """
def field_to_activity(self, value):
if not value:
return None
@ -405,8 +411,10 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
except (ParserError, TypeError):
return None
class HtmlField(ActivitypubFieldMixin, models.TextField):
''' a text field for storing html '''
""" a text field for storing html """
def field_from_activity(self, value):
if not value or value == MISSING:
return None
@ -414,19 +422,25 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
sanitizer.feed(value)
return sanitizer.get_output()
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
''' activitypub-aware array field '''
""" activitypub-aware array field """
def field_to_activity(self, value):
return [str(i) for i in value]
class CharField(ActivitypubFieldMixin, models.CharField):
''' activitypub-aware char field '''
""" activitypub-aware char field """
class TextField(ActivitypubFieldMixin, models.TextField):
''' activitypub-aware text field '''
""" activitypub-aware text field """
class BooleanField(ActivitypubFieldMixin, models.BooleanField):
''' activitypub-aware boolean field '''
""" activitypub-aware boolean field """
class IntegerField(ActivitypubFieldMixin, models.IntegerField):
''' activitypub-aware boolean field '''
""" activitypub-aware boolean field """

View file

@ -1,4 +1,4 @@
''' track progress of goodreads imports '''
""" track progress of goodreads imports """
import re
import dateutil.parser
@ -14,13 +14,14 @@ from .fields import PrivacyLevels
# Mapping goodreads -> bookwyrm shelf titles.
GOODREADS_SHELVES = {
'read': 'read',
'currently-reading': 'reading',
'to-read': 'to-read',
"read": "read",
"currently-reading": "reading",
"to-read": "to-read",
}
def unquote_string(text):
''' resolve csv quote weirdness '''
""" resolve csv quote weirdness """
match = re.match(r'="([^"]*)"', text)
if match:
return match.group(1)
@ -28,63 +29,57 @@ def unquote_string(text):
def construct_search_term(title, author):
''' formulate a query for the data connector '''
""" formulate a query for the data connector """
# Strip brackets (usually series title from search term)
title = re.sub(r'\s*\([^)]*\)\s*', '', title)
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)
author = re.sub(r"(\w\.)+\s*", "", author)
return ' '.join([title, author])
return " ".join([title, author])
class ImportJob(models.Model):
''' entry for a specific request for book data import '''
""" entry for a specific request for book data import """
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_date = models.DateTimeField(default=timezone.now)
task_id = models.CharField(max_length=100, null=True)
include_reviews = models.BooleanField(default=True)
complete = models.BooleanField(default=False)
privacy = models.CharField(
max_length=255,
default='public',
choices=PrivacyLevels.choices
max_length=255, default="public", choices=PrivacyLevels.choices
)
retry = models.BooleanField(default=False)
def save(self, *args, **kwargs):
''' save and notify '''
""" save and notify """
super().save(*args, **kwargs)
if self.complete:
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
"bookwyrm.Notification", require_ready=True
)
notification_model.objects.create(
user=self.user,
notification_type='IMPORT',
notification_type="IMPORT",
related_import=self,
)
class ImportItem(models.Model):
''' a single line of a csv being imported '''
job = models.ForeignKey(
ImportJob,
on_delete=models.CASCADE,
related_name='items')
""" a single line of a csv being imported """
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)
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()
)
""" 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 by isbn """
search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999
)
@ -93,13 +88,9 @@ class ImportItem(models.Model):
return search_result.connector.get_or_create_book(search_result.key)
return None
def get_book_from_title_author(self):
''' search by title and author '''
search_term = construct_search_term(
self.title,
self.author
)
""" search by title and author """
search_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result(
search_term, min_confidence=0.999
)
@ -108,84 +99,85 @@ class ImportItem(models.Model):
return search_result.connector.get_or_create_book(search_result.key)
return None
@property
def title(self):
''' get the book title '''
return self.data['Title']
""" get the book title """
return self.data["Title"]
@property
def author(self):
''' get the book title '''
return self.data['Author']
""" get the book title """
return self.data["Author"]
@property
def isbn(self):
''' pulls out the isbn13 field from the csv line data '''
return unquote_string(self.data['ISBN13'])
""" pulls out the isbn13 field from the csv line data """
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'])
""" the goodreads shelf field """
if self.data["Exclusive Shelf"]:
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
return None
@property
def review(self):
''' a user-written review, to be imported with the book data '''
return self.data['My Review']
""" a user-written review, to be imported with the book data """
return self.data["My Review"]
@property
def rating(self):
''' x/5 star rating for a book '''
return int(self.data['My Rating'])
""" x/5 star rating for a book """
return int(self.data["My Rating"])
@property
def date_added(self):
''' when the book was added to this dataset '''
if self.data['Date Added']:
return timezone.make_aware(
dateutil.parser.parse(self.data['Date Added']))
""" when the book was added to this dataset """
if self.data["Date Added"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
return None
@property
def date_started(self):
''' when the book was started '''
if "Date Started" in self.data and self.data['Date Started']:
return timezone.make_aware(
dateutil.parser.parse(self.data['Date Started']))
""" when the book was started """
if "Date Started" in self.data and self.data["Date Started"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
return None
@property
def date_read(self):
''' the date a book was completed '''
if self.data['Date Read']:
return timezone.make_aware(
dateutil.parser.parse(self.data['Date Read']))
""" the date a book was completed """
if self.data["Date Read"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
return None
@property
def reads(self):
''' formats a read through dataset for the book in this line '''
""" formats a read through dataset for the book in this line """
start_date = self.date_started
# Goodreads special case (no 'date started' field)
if ((self.shelf == 'reading' or (self.shelf == 'read' and self.date_read))
and self.date_added and not start_date):
if (
(self.shelf == "reading" or (self.shelf == "read" and self.date_read))
and self.date_added
and not start_date
):
start_date = self.date_added
if (start_date and start_date is not None and not self.date_read):
if start_date and start_date is not None and not self.date_read:
return [ReadThrough(start_date=start_date)]
if self.date_read:
return [ReadThrough(
return [
ReadThrough(
start_date=start_date,
finish_date=self.date_read,
)]
)
]
return []
def __repr__(self):
return "<{!r}Item {!r}>".format(self.data['import_source'], self.data['Title'])
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
def __str__(self):
return "{} by {}".format(self.data['Title'], self.data['Author'])
return "{} by {}".format(self.data["Title"], self.data["Author"])

View file

@ -1,4 +1,4 @@
''' make a list of books!! '''
""" make a list of books!! """
from django.apps import apps
from django.db import models
@ -9,86 +9,89 @@ from .base_model import BookWyrmModel
from . import fields
CurationType = models.TextChoices('Curation', [
'closed',
'open',
'curated',
])
CurationType = models.TextChoices(
"Curation",
[
"closed",
"open",
"curated",
],
)
class List(OrderedCollectionMixin, BookWyrmModel):
''' a list of books '''
""" a list of books """
name = fields.CharField(max_length=100)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='owner')
description = fields.TextField(
blank=True, null=True, activitypub_field='summary')
"User", on_delete=models.PROTECT, activitypub_field="owner"
)
description = fields.TextField(blank=True, null=True, activitypub_field="summary")
privacy = fields.PrivacyField()
curation = fields.CharField(
max_length=255,
default='closed',
choices=CurationType.choices
max_length=255, default="closed", choices=CurationType.choices
)
books = models.ManyToManyField(
'Edition',
"Edition",
symmetrical=False,
through='ListItem',
through_fields=('book_list', 'book'),
through="ListItem",
through_fields=("book_list", "book"),
)
activity_serializer = activitypub.BookList
def get_remote_id(self):
''' don't want the user to be in there in this case '''
return 'https://%s/list/%d' % (DOMAIN, self.id)
""" don't want the user to be in there in this case """
return "https://%s/list/%d" % (DOMAIN, self.id)
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books.filter(
listitem__approved=True
).all().order_by('listitem')
""" list of books for this shelf, overrides OrderedCollectionMixin """
return self.books.filter(listitem__approved=True).all().order_by("listitem")
class Meta:
''' default sorting '''
ordering = ('-updated_date',)
""" default sorting """
ordering = ("-updated_date",)
class ListItem(CollectionItemMixin, BookWyrmModel):
''' ok '''
""" ok """
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
"Edition", on_delete=models.PROTECT, activitypub_field="object"
)
book_list = fields.ForeignKey(
'List', on_delete=models.CASCADE, activitypub_field='target')
"List", on_delete=models.CASCADE, activitypub_field="target"
)
user = fields.ForeignKey(
'User',
on_delete=models.PROTECT,
activitypub_field='actor'
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
notes = fields.TextField(blank=True, null=True)
approved = models.BooleanField(default=True)
order = fields.IntegerField(blank=True, null=True)
endorsement = models.ManyToManyField('User', related_name='endorsers')
endorsement = models.ManyToManyField("User", related_name="endorsers")
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'book_list'
object_field = "book"
collection_field = "book_list"
def save(self, *args, **kwargs):
''' create a notification too '''
""" create a notification too """
created = not bool(self.id)
super().save(*args, **kwargs)
list_owner = self.book_list.user
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
model = apps.get_model('bookwyrm.Notification', require_ready=True)
model = apps.get_model("bookwyrm.Notification", require_ready=True)
model.objects.create(
user=list_owner,
related_user=self.user,
related_list_item=self,
notification_type='ADD',
notification_type="ADD",
)
class Meta:
''' an opinionated constraint! you can't put a book on a list twice '''
unique_together = ('book', 'book_list')
ordering = ('-created_date',)
""" an opinionated constraint! you can't put a book on a list twice """
unique_together = ("book", "book_list")
ordering = ("-created_date",)

View file

@ -1,32 +1,34 @@
''' alert a user to activity '''
""" alert a user to activity """
from django.db import models
from .base_model import BookWyrmModel
NotificationType = models.TextChoices(
'NotificationType',
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD')
"NotificationType",
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD",
)
class Notification(BookWyrmModel):
''' you've been tagged, liked, followed, etc '''
user = models.ForeignKey('User', on_delete=models.CASCADE)
related_book = models.ForeignKey(
'Edition', on_delete=models.CASCADE, null=True)
""" you've been tagged, liked, followed, etc """
user = models.ForeignKey("User", on_delete=models.CASCADE)
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
related_user = models.ForeignKey(
'User',
on_delete=models.CASCADE, null=True, related_name='related_user')
related_status = models.ForeignKey(
'Status', on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey(
'ImportJob', on_delete=models.CASCADE, null=True)
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
'ListItem', on_delete=models.CASCADE, null=True)
"ListItem", on_delete=models.CASCADE, null=True
)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices)
max_length=255, choices=NotificationType.choices
)
def save(self, *args, **kwargs):
''' save, but don't make dupes '''
""" save, but don't make dupes """
# there's probably a better way to do this
if self.__class__.objects.filter(
user=self.user,
@ -41,7 +43,8 @@ class Notification(BookWyrmModel):
super().save(*args, **kwargs)
class Meta:
''' checks if notifcation is in enum list for valid types '''
""" checks if notifcation is in enum list for valid types """
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),

View file

@ -1,35 +1,32 @@
''' progress in a book '''
""" progress in a book """
from django.db import models
from django.utils import timezone
from django.core import validators
from .base_model import BookWyrmModel
class ProgressMode(models.TextChoices):
PAGE = 'PG', 'page'
PERCENT = 'PCT', 'percent'
PAGE = "PG", "page"
PERCENT = "PCT", "percent"
class ReadThrough(BookWyrmModel):
''' Store a read through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
""" Store a read through a book in the database. """
user = models.ForeignKey("User", on_delete=models.PROTECT)
book = models.ForeignKey("Edition", on_delete=models.PROTECT)
progress = models.IntegerField(
validators=[validators.MinValueValidator(0)],
null=True,
blank=True)
validators=[validators.MinValueValidator(0)], null=True, blank=True
)
progress_mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE)
start_date = models.DateTimeField(
blank=True,
null=True)
finish_date = models.DateTimeField(
blank=True,
null=True)
max_length=3, choices=ProgressMode.choices, default=ProgressMode.PAGE
)
start_date = models.DateTimeField(blank=True, null=True)
finish_date = models.DateTimeField(blank=True, null=True)
def save(self, *args, **kwargs):
''' update user active time '''
""" update user active time """
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
@ -37,22 +34,22 @@ class ReadThrough(BookWyrmModel):
def create_update(self):
if self.progress:
return self.progressupdate_set.create(
user=self.user,
progress=self.progress,
mode=self.progress_mode)
user=self.user, progress=self.progress, mode=self.progress_mode
)
class ProgressUpdate(BookWyrmModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE)
""" Store progress through a book in the database. """
user = models.ForeignKey("User", on_delete=models.PROTECT)
readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
progress = models.IntegerField(validators=[validators.MinValueValidator(0)])
mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE)
max_length=3, choices=ProgressMode.choices, default=ProgressMode.PAGE
)
def save(self, *args, **kwargs):
''' update user active time '''
""" update user active time """
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)

View file

@ -1,4 +1,4 @@
''' defines relationships between users '''
""" defines relationships between users """
from django.apps import apps
from django.db import models, transaction, IntegrityError
from django.db.models import Q
@ -11,67 +11,70 @@ from . import fields
class UserRelationship(BookWyrmModel):
''' many-to-many through table for followers '''
""" many-to-many through table for followers """
user_subject = fields.ForeignKey(
'User',
"User",
on_delete=models.PROTECT,
related_name='%(class)s_user_subject',
activitypub_field='actor',
related_name="%(class)s_user_subject",
activitypub_field="actor",
)
user_object = fields.ForeignKey(
'User',
"User",
on_delete=models.PROTECT,
related_name='%(class)s_user_object',
activitypub_field='object',
related_name="%(class)s_user_object",
activitypub_field="object",
)
@property
def privacy(self):
''' all relationships are handled directly with the participants '''
return 'direct'
""" all relationships are handled directly with the participants """
return "direct"
@property
def recipients(self):
''' the remote user needs to recieve direct broadcasts '''
""" the remote user needs to recieve direct broadcasts """
return [u for u in [self.user_subject, self.user_object] if not u.local]
class Meta:
''' relationships should be unique '''
""" relationships should be unique """
abstract = True
constraints = [
models.UniqueConstraint(
fields=['user_subject', 'user_object'],
name='%(class)s_unique'
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'
)
check=~models.Q(user_subject=models.F("user_object")),
name="%(class)s_no_self",
),
]
def get_remote_id(self, status=None):# pylint: disable=arguments-differ
''' use shelf identifier in remote_id '''
status = status or 'follows'
def get_remote_id(self, status=None): # pylint: disable=arguments-differ
""" use shelf identifier in remote_id """
status = status or "follows"
base_path = self.user_subject.remote_id
return '%s#%s/%d' % (base_path, status, self.id)
return "%s#%s/%d" % (base_path, status, self.id)
class UserFollows(ActivityMixin, UserRelationship):
''' Following a user '''
status = 'follows'
""" Following a user """
status = "follows"
def to_activity(self):
''' overrides default to manually set serializer '''
""" overrides default to manually set serializer """
return activitypub.Follow(**generate_activity(self))
def save(self, *args, **kwargs):
''' really really don't let a user follow someone who blocked them '''
""" really really don't let a user follow someone who blocked them """
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
) | Q(
)
| Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
@ -83,7 +86,7 @@ class UserFollows(ActivityMixin, UserRelationship):
@classmethod
def from_request(cls, follow_request):
''' converts a follow request into a follow relationship '''
""" converts a follow request into a follow relationship """
return cls.objects.create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
@ -92,12 +95,13 @@ class UserFollows(ActivityMixin, UserRelationship):
class UserFollowRequest(ActivitypubMixin, UserRelationship):
''' following a user requires manual or automatic confirmation '''
status = 'follow_request'
""" following a user requires manual or automatic confirmation """
status = "follow_request"
activity_serializer = activitypub.Follow
def save(self, *args, broadcast=True, **kwargs):
''' make sure the follow or block relationship doesn't already exist '''
""" make sure the follow or block relationship doesn't already exist """
# don't create a request if a follow already exists
if UserFollows.objects.filter(
user_subject=self.user_subject,
@ -109,7 +113,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
Q(
user_subject=self.user_subject,
user_object=self.user_object,
) | Q(
)
| Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
@ -125,39 +130,35 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
if not manually_approves:
self.accept()
model = apps.get_model('bookwyrm.Notification', require_ready=True)
notification_type = 'FOLLOW_REQUEST' if \
manually_approves else 'FOLLOW'
model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_type = "FOLLOW_REQUEST" if manually_approves else "FOLLOW"
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
def accept(self):
''' turn this request into the real deal'''
""" turn this request into the real deal"""
user = self.user_object
if not self.user_subject.local:
activity = activitypub.Accept(
id=self.get_remote_id(status='accepts'),
id=self.get_remote_id(status="accepts"),
actor=self.user_object.remote_id,
object=self.to_activity()
object=self.to_activity(),
).serialize()
self.broadcast(activity, user)
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
def reject(self):
''' generate a Reject for this follow request '''
""" generate a Reject for this follow request """
if self.user_object.local:
activity = activitypub.Reject(
id=self.get_remote_id(status='rejects'),
id=self.get_remote_id(status="rejects"),
actor=self.user_object.remote_id,
object=self.to_activity()
object=self.to_activity(),
).serialize()
self.broadcast(activity, self.user_object)
@ -165,19 +166,20 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
class UserBlocks(ActivityMixin, UserRelationship):
''' prevent another user from following you and seeing your posts '''
status = 'blocks'
""" prevent another user from following you and seeing your posts """
status = "blocks"
activity_serializer = activitypub.Block
def save(self, *args, **kwargs):
''' remove follow or follow request rels after a block is created '''
""" remove follow or follow request rels after a block is created """
super().save(*args, **kwargs)
UserFollows.objects.filter(
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.user_subject)
Q(user_subject=self.user_subject, user_object=self.user_object)
| Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()
UserFollowRequest.objects.filter(
Q(user_subject=self.user_subject, user_object=self.user_object) | \
Q(user_subject=self.user_object, user_object=self.user_subject)
Q(user_subject=self.user_subject, user_object=self.user_object)
| Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()

View file

@ -1,4 +1,4 @@
''' puttin' books on shelves '''
""" puttin' books on shelves """
import re
from django.db import models
@ -9,61 +9,68 @@ from . import fields
class Shelf(OrderedCollectionMixin, BookWyrmModel):
''' a list of books owned by a user '''
""" a list of books owned by a user """
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='owner')
"User", on_delete=models.PROTECT, activitypub_field="owner"
)
editable = models.BooleanField(default=True)
privacy = fields.PrivacyField()
books = models.ManyToManyField(
'Edition',
"Edition",
symmetrical=False,
through='ShelfBook',
through_fields=('shelf', 'book')
through="ShelfBook",
through_fields=("shelf", "book"),
)
activity_serializer = activitypub.Shelf
def save(self, *args, **kwargs):
''' set the identifier '''
""" set the identifier """
super().save(*args, **kwargs)
if not self.identifier:
slug = re.sub(r'[^\w]', '', self.name).lower()
self.identifier = '%s-%d' % (slug, self.id)
slug = re.sub(r"[^\w]", "", self.name).lower()
self.identifier = "%s-%d" % (slug, self.id)
super().save(*args, **kwargs)
@property
def collection_queryset(self):
''' list of books for this shelf, overrides OrderedCollectionMixin '''
return self.books.all().order_by('shelfbook')
""" list of books for this shelf, overrides OrderedCollectionMixin """
return self.books.all().order_by("shelfbook")
def get_remote_id(self):
''' shelf identifier instead of id '''
""" shelf identifier instead of id """
base_path = self.user.remote_id
return '%s/shelf/%s' % (base_path, self.identifier)
return "%s/shelf/%s" % (base_path, self.identifier)
class Meta:
''' user/shelf unqiueness '''
unique_together = ('user', 'identifier')
""" user/shelf unqiueness """
unique_together = ("user", "identifier")
class ShelfBook(CollectionItemMixin, BookWyrmModel):
''' many to many join table for books and shelves '''
""" many to many join table for books and shelves """
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
"Edition", on_delete=models.PROTECT, activitypub_field="object"
)
shelf = fields.ForeignKey(
'Shelf', on_delete=models.PROTECT, activitypub_field='target')
"Shelf", on_delete=models.PROTECT, activitypub_field="target"
)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'shelf'
object_field = "book"
collection_field = "shelf"
class Meta:
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf')
ordering = ('-created_date',)
"""an opinionated constraint!
you can't put a book on shelf twice"""
unique_together = ("book", "shelf")
ordering = ("-created_date",)

View file

@ -1,4 +1,4 @@
''' the particulars for this instance of BookWyrm '''
""" the particulars for this instance of BookWyrm """
import base64
import datetime
@ -9,36 +9,31 @@ from django.utils import timezone
from bookwyrm.settings import DOMAIN
from .user import User
class SiteSettings(models.Model):
''' customized settings for this instance '''
name = models.CharField(default='BookWyrm', max_length=100)
""" customized settings for this instance """
name = models.CharField(default="BookWyrm", max_length=100)
instance_tagline = models.CharField(
max_length=150, default='Social Reading and Reviewing')
instance_description = models.TextField(
default='This instance has no description.')
max_length=150, default="Social Reading and Reviewing"
)
instance_description = models.TextField(default="This instance has no description.")
registration_closed_text = models.TextField(
default='Contact an administrator to get an invite')
code_of_conduct = models.TextField(
default='Add a code of conduct here.')
privacy_policy = models.TextField(
default='Add a privacy policy here.')
default="Contact an administrator to get an invite"
)
code_of_conduct = models.TextField(default="Add a code of conduct here.")
privacy_policy = models.TextField(default="Add a privacy policy here.")
allow_registration = models.BooleanField(default=True)
logo = models.ImageField(
upload_to='logos/', null=True, blank=True
)
logo_small = models.ImageField(
upload_to='logos/', null=True, blank=True
)
favicon = models.ImageField(
upload_to='logos/', null=True, blank=True
)
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
favicon = models.ImageField(upload_to="logos/", null=True, blank=True)
support_link = models.CharField(max_length=255, null=True, blank=True)
support_title = models.CharField(max_length=100, null=True, blank=True)
admin_email = models.EmailField(max_length=255, null=True, blank=True)
@classmethod
def get(cls):
''' gets the site settings db entry or defaults '''
""" gets the site settings db entry or defaults """
try:
return cls.objects.get(id=1)
except cls.DoesNotExist:
@ -46,12 +41,15 @@ class SiteSettings(models.Model):
default_settings.save()
return default_settings
def new_access_code():
''' the identifier for a user invite '''
return base64.b32encode(Random.get_random_bytes(5)).decode('ascii')
""" the identifier for a user invite """
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
class SiteInvite(models.Model):
''' gives someone access to create an account on the instance '''
""" gives someone access to create an account on the instance """
created_date = models.DateTimeField(auto_now_add=True)
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(blank=True, null=True)
@ -60,34 +58,35 @@ class SiteInvite(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
def valid(self):
''' make sure it hasn't expired or been used '''
return (
(self.expiry is None or self.expiry > timezone.now()) and
(self.use_limit is None or self.times_used < self.use_limit))
""" make sure it hasn't expired or been used """
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):
''' formats the invite link '''
return 'https://{}/invite/{}'.format(DOMAIN, self.code)
""" formats the invite link """
return "https://{}/invite/{}".format(DOMAIN, self.code)
def get_passowrd_reset_expiry():
''' give people a limited time to use the link '''
""" give people a limited time to use the link """
now = timezone.now()
return now + datetime.timedelta(days=1)
class PasswordReset(models.Model):
''' gives someone access to create an account on the instance '''
""" gives someone access to create an account on the instance """
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
user = models.OneToOneField(User, on_delete=models.CASCADE)
def valid(self):
''' make sure it hasn't expired or been used '''
""" make sure it hasn't expired or been used """
return self.expiry > timezone.now()
@property
def link(self):
''' formats the invite link '''
return 'https://{}/password-reset/{}'.format(DOMAIN, self.code)
""" formats the invite link """
return "https://{}/password-reset/{}".format(DOMAIN, self.code)

View file

@ -1,4 +1,4 @@
''' models for storing different kinds of Activities '''
""" models for storing different kinds of Activities """
from dataclasses import MISSING
import re
@ -17,76 +17,81 @@ from . import fields
class Status(OrderedCollectionPageMixin, BookWyrmModel):
''' any post, like a reply to a review, etc '''
""" any post, like a reply to a review, etc """
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
)
content = fields.HtmlField(blank=True, null=True)
mention_users = fields.TagField('User', related_name='mention_user')
mention_books = fields.TagField('Edition', related_name='mention_book')
mention_users = fields.TagField("User", related_name="mention_user")
mention_books = fields.TagField("Edition", related_name="mention_book")
local = models.BooleanField(default=True)
content_warning = fields.CharField(
max_length=500, blank=True, null=True, activitypub_field='summary')
max_length=500, blank=True, null=True, activitypub_field="summary"
)
privacy = fields.PrivacyField(max_length=255)
sensitive = fields.BooleanField(default=False)
# created date is different than publish date because of federated posts
published_date = fields.DateTimeField(
default=timezone.now, activitypub_field='published')
default=timezone.now, activitypub_field="published"
)
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
'User',
"User",
symmetrical=False,
through='Favorite',
through_fields=('status', 'user'),
related_name='user_favorites'
through="Favorite",
through_fields=("status", "user"),
related_name="user_favorites",
)
reply_parent = fields.ForeignKey(
'self',
"self",
null=True,
on_delete=models.PROTECT,
activitypub_field='inReplyTo',
activitypub_field="inReplyTo",
)
objects = InheritanceManager()
activity_serializer = activitypub.Note
serialize_reverse_fields = [('attachments', 'attachment', 'id')]
deserialize_reverse_fields = [('attachments', 'attachment')]
serialize_reverse_fields = [("attachments", "attachment", "id")]
deserialize_reverse_fields = [("attachments", "attachment")]
def save(self, *args, **kwargs):
''' save and notify '''
""" save and notify """
super().save(*args, **kwargs)
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
if self.deleted:
notification_model.objects.filter(related_status=self).delete()
if self.reply_parent and self.reply_parent.user != self.user and \
self.reply_parent.user.local:
if (
self.reply_parent
and self.reply_parent.user != self.user
and self.reply_parent.user.local
):
notification_model.objects.create(
user=self.reply_parent.user,
notification_type='REPLY',
notification_type="REPLY",
related_user=self.user,
related_status=self,
)
for mention_user in self.mention_users.all():
# avoid double-notifying about this status
if not mention_user.local or \
(self.reply_parent and \
mention_user == self.reply_parent.user):
if not mention_user.local or (
self.reply_parent and mention_user == self.reply_parent.user
):
continue
notification_model.objects.create(
user=mention_user,
notification_type='MENTION',
notification_type="MENTION",
related_user=self.user,
related_status=self,
)
def delete(self, *args, **kwargs):#pylint: disable=unused-argument
''' "delete" a status '''
if hasattr(self, 'boosted_status'):
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status """
if hasattr(self, "boosted_status"):
# okay but if it's a boost really delete it
super().delete(*args, **kwargs)
return
@ -96,141 +101,154 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property
def recipients(self):
''' tagged users who definitely need to get this status in broadcast '''
""" tagged users who definitely need to get this status in broadcast """
mentions = [u for u in self.mention_users.all() if not u.local]
if hasattr(self, 'reply_parent') and self.reply_parent \
and not self.reply_parent.user.local:
if (
hasattr(self, "reply_parent")
and self.reply_parent
and not self.reply_parent.user.local
):
mentions.append(self.reply_parent.user)
return list(set(mentions))
@classmethod
def ignore_activity(cls, activity):
''' keep notes if they are replies to existing statuses '''
if activity.type == 'Announce':
""" keep notes if they are replies to existing statuses """
if activity.type == "Announce":
# keep it if the booster or the boosted are local
boosted = activitypub.resolve_remote_id(activity.object, save=False)
return cls.ignore_activity(boosted.to_activity_dataclass())
# keep if it if it's a custom type
if activity.type != 'Note':
if activity.type != "Note":
return False
if cls.objects.filter(
remote_id=activity.inReplyTo).exists():
if cls.objects.filter(remote_id=activity.inReplyTo).exists():
return False
# keep notes if they mention local users
if activity.tag == MISSING or activity.tag is None:
return True
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
user_model = apps.get_model('bookwyrm.User', require_ready=True)
tags = [l["href"] for l in activity.tag if l["type"] == "Mention"]
user_model = apps.get_model("bookwyrm.User", require_ready=True)
for tag in tags:
if user_model.objects.filter(
remote_id=tag, local=True).exists():
if user_model.objects.filter(remote_id=tag, local=True).exists():
# we found a mention of a known use boost
return False
return True
@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().order_by('published_date')
"""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()
.order_by("published_date")
)
@property
def status_type(self):
''' expose the type of status for the ui using activity type '''
""" expose the type of status for the ui using activity type """
return self.activity_serializer.__name__
@property
def boostable(self):
''' you can't boost dms '''
return self.privacy in ['unlisted', 'public']
""" you can't boost dms """
return self.privacy in ["unlisted", "public"]
def to_replies(self, **kwargs):
''' helper function for loading AP serialized replies to a status '''
""" 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,
remote_id="%s/replies" % self.remote_id,
collection_only=True,
**kwargs
).serialize()
def to_activity_dataclass(self, pure=False):# pylint: disable=arguments-differ
''' return tombstone if the status is deleted '''
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
""" return tombstone if the status is deleted """
if self.deleted:
return activitypub.Tombstone(
id=self.remote_id,
url=self.remote_id,
deleted=self.deleted_date.isoformat(),
published=self.deleted_date.isoformat()
published=self.deleted_date.isoformat(),
)
activity = ActivitypubMixin.to_activity_dataclass(self)
activity.replies = self.to_replies()
# "pure" serialization for non-bookwyrm instances
if pure and hasattr(self, 'pure_content'):
if pure and hasattr(self, "pure_content"):
activity.content = self.pure_content
if hasattr(activity, 'name'):
if hasattr(activity, "name"):
activity.name = self.pure_name
activity.type = self.pure_type
activity.attachment = [
image_serializer(b.cover, b.alt_text) \
for b in self.mention_books.all()[:4] if b.cover]
if hasattr(self, 'book') and self.book.cover:
image_serializer(b.cover, b.alt_text)
for b in self.mention_books.all()[:4]
if b.cover
]
if hasattr(self, "book") and self.book.cover:
activity.attachment.append(
image_serializer(self.book.cover, self.book.alt_text)
)
return activity
def to_activity(self, pure=False):# pylint: disable=arguments-differ
''' json serialized activitypub class '''
def to_activity(self, pure=False): # pylint: disable=arguments-differ
""" json serialized activitypub class """
return self.to_activity_dataclass(pure=pure).serialize()
class GeneratedNote(Status):
''' these are app-generated messages about user activity '''
""" these are app-generated messages about user activity """
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
""" indicate the book in question for mastodon (or w/e) users """
message = self.content
books = ', '.join(
'<a href="%s">"%s"</a>' % (book.remote_id, book.title) \
books = ", ".join(
'<a href="%s">"%s"</a>' % (book.remote_id, book.title)
for book in self.mention_books.all()
)
return '%s %s %s' % (self.user.display_name, message, books)
return "%s %s %s" % (self.user.display_name, message, books)
activity_serializer = activitypub.GeneratedNote
pure_type = 'Note'
pure_type = "Note"
class Comment(Status):
''' like a review but without a rating and transient '''
""" like a review but without a rating and transient """
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
)
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % \
(self.content, self.book.remote_id, self.book.title)
""" indicate the book in question for mastodon (or w/e) users """
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
self.content,
self.book.remote_id,
self.book.title,
)
activity_serializer = activitypub.Comment
pure_type = 'Note'
pure_type = "Note"
class Quotation(Status):
''' like a review but without a rating and transient '''
""" like a review but without a rating and transient """
quote = fields.HtmlField()
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
)
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
quote = re.sub(r'^<p>', '<p>"', self.quote)
quote = re.sub(r'</p>$', '"</p>', quote)
""" indicate the book in question for mastodon (or w/e) users """
quote = re.sub(r"^<p>", '<p>"', self.quote)
quote = re.sub(r"</p>$", '"</p>', quote)
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
quote,
self.book.remote_id,
@ -239,90 +257,86 @@ class Quotation(Status):
)
activity_serializer = activitypub.Quotation
pure_type = 'Note'
pure_type = "Note"
class Review(Status):
''' a book review '''
""" a book review """
name = fields.CharField(max_length=255, null=True)
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
)
rating = fields.IntegerField(
default=None,
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)]
validators=[MinValueValidator(1), MaxValueValidator(5)],
)
@property
def pure_name(self):
''' clarify review names for mastodon serialization '''
""" clarify review names for mastodon serialization """
if self.rating:
#pylint: disable=bad-string-format-type
# pylint: disable=bad-string-format-type
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
self.name
)
return 'Review of "%s": %s' % (
self.book.title,
self.name
self.name,
)
return 'Review of "%s": %s' % (self.book.title, self.name)
@property
def pure_content(self):
''' indicate the book in question for mastodon (or w/e) users '''
""" indicate the book in question for mastodon (or w/e) users """
return self.content
activity_serializer = activitypub.Review
pure_type = 'Article'
pure_type = "Article"
class Boost(ActivityMixin, Status):
''' boost'ing a post '''
""" boost'ing a post """
boosted_status = fields.ForeignKey(
'Status',
"Status",
on_delete=models.PROTECT,
related_name='boosters',
activitypub_field='object',
related_name="boosters",
activitypub_field="object",
)
activity_serializer = activitypub.Announce
def save(self, *args, **kwargs):
''' save and notify '''
""" save and notify """
super().save(*args, **kwargs)
if not self.boosted_status.user.local:
return
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_model.objects.create(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
notification_type="BOOST",
)
def delete(self, *args, **kwargs):
''' delete and un-notify '''
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
""" delete and un-notify """
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_model.objects.filter(
user=self.boosted_status.user,
related_status=self.boosted_status,
related_user=self.user,
notification_type='BOOST',
notification_type="BOOST",
).delete()
super().delete(*args, **kwargs)
def __init__(self, *args, **kwargs):
''' the user field is "actor" here instead of "attributedTo" '''
""" the user field is "actor" here instead of "attributedTo" """
super().__init__(*args, **kwargs)
reserve_fields = ['user', 'boosted_status']
self.simple_fields = [f for f in self.simple_fields if \
f.name in reserve_fields]
reserve_fields = ["user", "boosted_status"]
self.simple_fields = [f for f in self.simple_fields if f.name in reserve_fields]
self.activity_fields = self.simple_fields
self.many_to_many_fields = []
self.image_fields = []

View file

@ -1,4 +1,4 @@
''' models for storing different kinds of Activities '''
""" models for storing different kinds of Activities """
import urllib.parse
from django.apps import apps
@ -12,28 +12,30 @@ from . import fields
class Tag(OrderedCollectionMixin, BookWyrmModel):
''' freeform tags for books '''
""" freeform tags for books """
name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100)
@property
def books(self):
''' count of books associated with this tag '''
edition_model = apps.get_model('bookwyrm.Edition', require_ready=True)
return edition_model.objects.filter(
usertag__tag__identifier=self.identifier
).order_by('-created_date').distinct()
""" count of books associated with this tag """
edition_model = apps.get_model("bookwyrm.Edition", require_ready=True)
return (
edition_model.objects.filter(usertag__tag__identifier=self.identifier)
.order_by("-created_date")
.distinct()
)
collection_queryset = books
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)
""" tag should use identifier not id in remote_id """
base_path = "https://%s" % DOMAIN
return "%s/tag/%s" % (base_path, self.identifier)
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
""" 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)
@ -41,18 +43,21 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
class UserTag(CollectionItemMixin, BookWyrmModel):
''' an instance of a tag on a book by a user '''
""" an instance of a tag on a book by a user """
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
tag = fields.ForeignKey(
'Tag', on_delete=models.PROTECT, activitypub_field='target')
"Edition", on_delete=models.PROTECT, activitypub_field="object"
)
tag = fields.ForeignKey("Tag", on_delete=models.PROTECT, activitypub_field="target")
activity_serializer = activitypub.Add
object_field = 'book'
collection_field = 'tag'
object_field = "book"
collection_field = "tag"
class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'book', 'tag')
""" unqiueness constraint """
unique_together = ("user", "book", "tag")

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