Merge branch 'main' into review-rate
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
''' bring all the models into the app namespace '''
|
||||
""" bring all the models into the app namespace """
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
@ -28,8 +28,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)
|
||||
]
|
||||
|
@ -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"
|
||||
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 '''
|
||||
def to_activity(self, **kwargs): # pylint: disable=unused-argument
|
||||
""" 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,23 +450,23 @@ 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:
|
||||
sign_and_send(sender, activity, recipient)
|
||||
except (HTTPError, SSLError) as e:
|
||||
except (HTTPError, SSLError, ConnectionError) as e:
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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,11 +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 """
|
||||
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)
|
||||
@ -59,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)
|
||||
|
||||
@ -69,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 cover' % 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(
|
||||
@ -115,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
|
||||
@ -200,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)
|
||||
@ -214,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
|
||||
@ -235,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]
|
||||
@ -252,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)
|
||||
|
@ -1,29 +1,30 @@
|
||||
''' 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)
|
||||
books_url = models.CharField(max_length=255)
|
||||
covers_url = models.CharField(max_length=255)
|
||||
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)
|
||||
@ -31,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",
|
||||
)
|
||||
]
|
||||
|
||||
|
@ -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
|
||||
@ -7,46 +7,61 @@ from bookwyrm import activitypub
|
||||
from .activitypub_mixin import ActivityMixin
|
||||
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 """
|
||||
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")
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
href=item.remote_id,
|
||||
name=getattr(item, item.name_field),
|
||||
type=activity_type
|
||||
))
|
||||
if activity_type == "User":
|
||||
activity_type = "Mention"
|
||||
tags.append(
|
||||
activitypub.Link(
|
||||
href=item.remote_id,
|
||||
name=getattr(item, item.name_field),
|
||||
type=activity_type,
|
||||
)
|
||||
)
|
||||
return tags
|
||||
|
||||
def field_from_activity(self, value):
|
||||
@ -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 """
|
||||
|
@ -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(
|
||||
start_date=start_date,
|
||||
finish_date=self.date_read,
|
||||
)]
|
||||
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"])
|
||||
|
@ -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",)
|
||||
|
@ -1,47 +1,50 @@
|
||||
''' 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,
|
||||
related_book=self.related_book,
|
||||
related_user=self.related_user,
|
||||
related_status=self.related_status,
|
||||
related_import=self.related_import,
|
||||
related_list_item=self.related_list_item,
|
||||
notification_type=self.notification_type,
|
||||
).exists():
|
||||
user=self.user,
|
||||
related_book=self.related_book,
|
||||
related_user=self.related_user,
|
||||
related_status=self.related_status,
|
||||
related_import=self.related_import,
|
||||
related_list_item=self.related_list_item,
|
||||
notification_type=self.notification_type,
|
||||
).exists():
|
||||
return
|
||||
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),
|
||||
|
@ -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)
|
||||
|
@ -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,71 +11,74 @@ 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(
|
||||
user_subject=self.user_object,
|
||||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
Q(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user_object,
|
||||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
# don't broadcast this type of relationship -- accepts and requests
|
||||
# are handled by the UserFollowRequest model
|
||||
@ -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,28 +95,30 @@ 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,
|
||||
user_object=self.user_object,
|
||||
).exists():
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
) | Q(
|
||||
user_subject=self.user_object,
|
||||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
Q(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user_object,
|
||||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@ -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()
|
||||
|
@ -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",)
|
||||
|
@ -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)
|
||||
|
@ -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,42 +257,41 @@ 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:
|
||||
return 'Review of "{}" ({:d} stars): {}'.format(
|
||||
self.book.title,
|
||||
self.rating,
|
||||
self.name
|
||||
self.name,
|
||||
)
|
||||
return 'Review of "{}": {}'.format(
|
||||
self.book.title,
|
||||
self.name
|
||||
)
|
||||
return 'Review of "{}": {}'.format(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 ReviewRating(Review):
|
||||
@ -294,50 +311,47 @@ class ReviewRating(Review):
|
||||
|
||||
|
||||
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 = []
|
||||
|
@ -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")
|
||||
|
@ -1,9 +1,9 @@
|
||||
''' database schema for user data '''
|
||||
""" database schema for user data """
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
@ -23,25 +23,28 @@ from . import fields, Review
|
||||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
''' a user who wants to read books '''
|
||||
""" a user who wants to read books """
|
||||
|
||||
username = fields.UsernameField()
|
||||
email = models.EmailField(unique=True, null=True)
|
||||
|
||||
key_pair = fields.OneToOneField(
|
||||
'KeyPair',
|
||||
"KeyPair",
|
||||
on_delete=models.CASCADE,
|
||||
blank=True, null=True,
|
||||
activitypub_field='publicKey',
|
||||
related_name='owner'
|
||||
blank=True,
|
||||
null=True,
|
||||
activitypub_field="publicKey",
|
||||
related_name="owner",
|
||||
)
|
||||
inbox = fields.RemoteIdField(unique=True)
|
||||
shared_inbox = fields.RemoteIdField(
|
||||
activitypub_field='sharedInbox',
|
||||
activitypub_wrapper='endpoints',
|
||||
activitypub_field="sharedInbox",
|
||||
activitypub_wrapper="endpoints",
|
||||
deduplication_field=False,
|
||||
null=True)
|
||||
null=True,
|
||||
)
|
||||
federated_server = models.ForeignKey(
|
||||
'FederatedServer',
|
||||
"FederatedServer",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
blank=True,
|
||||
@ -59,54 +62,58 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
# name is your display name, which you can change at will
|
||||
name = fields.CharField(max_length=100, null=True, blank=True)
|
||||
avatar = fields.ImageField(
|
||||
upload_to='avatars/', blank=True, null=True,
|
||||
activitypub_field='icon', alt_field='alt_text')
|
||||
upload_to="avatars/",
|
||||
blank=True,
|
||||
null=True,
|
||||
activitypub_field="icon",
|
||||
alt_field="alt_text",
|
||||
)
|
||||
followers = fields.ManyToManyField(
|
||||
'self',
|
||||
"self",
|
||||
link_only=True,
|
||||
symmetrical=False,
|
||||
through='UserFollows',
|
||||
through_fields=('user_object', 'user_subject'),
|
||||
related_name='following'
|
||||
through="UserFollows",
|
||||
through_fields=("user_object", "user_subject"),
|
||||
related_name="following",
|
||||
)
|
||||
follow_requests = models.ManyToManyField(
|
||||
'self',
|
||||
"self",
|
||||
symmetrical=False,
|
||||
through='UserFollowRequest',
|
||||
through_fields=('user_subject', 'user_object'),
|
||||
related_name='follower_requests'
|
||||
through="UserFollowRequest",
|
||||
through_fields=("user_subject", "user_object"),
|
||||
related_name="follower_requests",
|
||||
)
|
||||
blocks = models.ManyToManyField(
|
||||
'self',
|
||||
"self",
|
||||
symmetrical=False,
|
||||
through='UserBlocks',
|
||||
through_fields=('user_subject', 'user_object'),
|
||||
related_name='blocked_by'
|
||||
through="UserBlocks",
|
||||
through_fields=("user_subject", "user_object"),
|
||||
related_name="blocked_by",
|
||||
)
|
||||
favorites = models.ManyToManyField(
|
||||
'Status',
|
||||
"Status",
|
||||
symmetrical=False,
|
||||
through='Favorite',
|
||||
through_fields=('user', 'status'),
|
||||
related_name='favorite_statuses'
|
||||
through="Favorite",
|
||||
through_fields=("user", "status"),
|
||||
related_name="favorite_statuses",
|
||||
)
|
||||
remote_id = fields.RemoteIdField(
|
||||
null=True, unique=True, activitypub_field='id')
|
||||
remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id")
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
last_active_date = models.DateTimeField(auto_now=True)
|
||||
manually_approves_followers = fields.BooleanField(default=False)
|
||||
|
||||
name_field = 'username'
|
||||
name_field = "username"
|
||||
|
||||
@property
|
||||
def alt_text(self):
|
||||
''' alt text with username '''
|
||||
return 'avatar for %s' % (self.localname or self.username)
|
||||
""" alt text with username """
|
||||
return "avatar for %s" % (self.localname or self.username)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
''' show the cleanest version of the user's name possible '''
|
||||
if self.name and self.name != '':
|
||||
""" show the cleanest version of the user's name possible """
|
||||
if self.name and self.name != "":
|
||||
return self.name
|
||||
return self.localname or self.username
|
||||
|
||||
@ -114,78 +121,82 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
|
||||
@classmethod
|
||||
def viewer_aware_objects(cls, viewer):
|
||||
''' the user queryset filtered for the context of the logged in user '''
|
||||
""" the user queryset filtered for the context of the logged in user """
|
||||
queryset = cls.objects.filter(is_active=True)
|
||||
if viewer.is_authenticated:
|
||||
queryset = queryset.exclude(
|
||||
blocks=viewer
|
||||
)
|
||||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
''' an ordered collection of statuses '''
|
||||
""" an ordered collection of statuses """
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
'bookwyrm.%s' % filter_type, require_ready=True)
|
||||
"bookwyrm.%s" % filter_type, require_ready=True
|
||||
)
|
||||
if not issubclass(filter_class, Status):
|
||||
raise TypeError(
|
||||
'filter_status_class must be a subclass of models.Status')
|
||||
"filter_status_class must be a subclass of models.Status"
|
||||
)
|
||||
queryset = filter_class.objects
|
||||
else:
|
||||
queryset = Status.objects
|
||||
|
||||
queryset = queryset.filter(
|
||||
user=self,
|
||||
deleted=False,
|
||||
privacy__in=['public', 'unlisted'],
|
||||
).select_subclasses().order_by('-published_date')
|
||||
return self.to_ordered_collection(queryset, \
|
||||
collection_only=True, remote_id=self.outbox, **kwargs).serialize()
|
||||
queryset = (
|
||||
queryset.filter(
|
||||
user=self,
|
||||
deleted=False,
|
||||
privacy__in=["public", "unlisted"],
|
||||
)
|
||||
.select_subclasses()
|
||||
.order_by("-published_date")
|
||||
)
|
||||
return self.to_ordered_collection(
|
||||
queryset, collection_only=True, remote_id=self.outbox, **kwargs
|
||||
).serialize()
|
||||
|
||||
def to_following_activity(self, **kwargs):
|
||||
''' activitypub following list '''
|
||||
remote_id = '%s/following' % self.remote_id
|
||||
""" activitypub following list """
|
||||
remote_id = "%s/following" % self.remote_id
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by('-updated_date').all(),
|
||||
self.following.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_followers_activity(self, **kwargs):
|
||||
''' activitypub followers list '''
|
||||
remote_id = '%s/followers' % self.remote_id
|
||||
""" activitypub followers list """
|
||||
remote_id = "%s/followers" % self.remote_id
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by('-updated_date').all(),
|
||||
self.followers.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_activity(self):
|
||||
''' override default AP serializer to add context object
|
||||
idk if this is the best way to go about this '''
|
||||
"""override default AP serializer to add context object
|
||||
idk if this is the best way to go about this"""
|
||||
activity_object = super().to_activity()
|
||||
activity_object['@context'] = [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
activity_object["@context"] = [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{
|
||||
'manuallyApprovesFollowers': 'as:manuallyApprovesFollowers',
|
||||
'schema': 'http://schema.org#',
|
||||
'PropertyValue': 'schema:PropertyValue',
|
||||
'value': 'schema:value',
|
||||
}
|
||||
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
|
||||
"schema": "http://schema.org#",
|
||||
"PropertyValue": "schema:PropertyValue",
|
||||
"value": "schema:value",
|
||||
},
|
||||
]
|
||||
return activity_object
|
||||
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' populate fields for new local users '''
|
||||
""" populate fields for new local users """
|
||||
created = not bool(self.id)
|
||||
if not self.local and not re.match(regex.full_username, self.username):
|
||||
# generate a username that uses the domain (webfinger format)
|
||||
actor_parts = urlparse(self.remote_id)
|
||||
self.username = '%s@%s' % (self.username, actor_parts.netloc)
|
||||
self.username = "%s@%s" % (self.username, actor_parts.netloc)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# this user already exists, no need to populate fields
|
||||
@ -200,107 +211,120 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
return
|
||||
|
||||
# populate fields for local users
|
||||
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.localname)
|
||||
self.inbox = '%s/inbox' % self.remote_id
|
||||
self.shared_inbox = 'https://%s/inbox' % DOMAIN
|
||||
self.outbox = '%s/outbox' % self.remote_id
|
||||
self.remote_id = "https://%s/user/%s" % (DOMAIN, self.localname)
|
||||
self.inbox = "%s/inbox" % self.remote_id
|
||||
self.shared_inbox = "https://%s/inbox" % DOMAIN
|
||||
self.outbox = "%s/outbox" % self.remote_id
|
||||
|
||||
# an id needs to be set before we can proceed with related models
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# make users editors by default
|
||||
try:
|
||||
self.groups.add(Group.objects.get(name="editor"))
|
||||
except Group.DoesNotExist:
|
||||
# this should only happen in tests
|
||||
pass
|
||||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id='%s/#main-key' % self.remote_id)
|
||||
remote_id="%s/#main-key" % self.remote_id
|
||||
)
|
||||
self.save(broadcast=False)
|
||||
|
||||
shelves = [{
|
||||
'name': 'To Read',
|
||||
'identifier': 'to-read',
|
||||
}, {
|
||||
'name': 'Currently Reading',
|
||||
'identifier': 'reading',
|
||||
}, {
|
||||
'name': 'Read',
|
||||
'identifier': 'read',
|
||||
}]
|
||||
shelves = [
|
||||
{
|
||||
"name": "To Read",
|
||||
"identifier": "to-read",
|
||||
},
|
||||
{
|
||||
"name": "Currently Reading",
|
||||
"identifier": "reading",
|
||||
},
|
||||
{
|
||||
"name": "Read",
|
||||
"identifier": "read",
|
||||
},
|
||||
]
|
||||
|
||||
for shelf in shelves:
|
||||
Shelf(
|
||||
name=shelf['name'],
|
||||
identifier=shelf['identifier'],
|
||||
name=shelf["name"],
|
||||
identifier=shelf["identifier"],
|
||||
user=self,
|
||||
editable=False
|
||||
editable=False,
|
||||
).save(broadcast=False)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
''' this model doesn't inherit bookwyrm model, so here we are '''
|
||||
return '/user/%s' % (self.localname or self.username)
|
||||
""" this model doesn't inherit bookwyrm model, so here we are """
|
||||
return "/user/%s" % (self.localname or self.username)
|
||||
|
||||
|
||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
''' public and private keys for a user '''
|
||||
""" public and private keys for a user """
|
||||
|
||||
private_key = models.TextField(blank=True, null=True)
|
||||
public_key = fields.TextField(
|
||||
blank=True, null=True, activitypub_field='publicKeyPem')
|
||||
blank=True, null=True, activitypub_field="publicKeyPem"
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.PublicKey
|
||||
serialize_reverse_fields = [('owner', 'owner', 'id')]
|
||||
serialize_reverse_fields = [("owner", "owner", "id")]
|
||||
|
||||
def get_remote_id(self):
|
||||
# self.owner is set by the OneToOneField on User
|
||||
return '%s/#main-key' % self.owner.remote_id
|
||||
return "%s/#main-key" % self.owner.remote_id
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' create a key pair '''
|
||||
""" create a key pair """
|
||||
# no broadcasting happening here
|
||||
if 'broadcast' in kwargs:
|
||||
del kwargs['broadcast']
|
||||
if "broadcast" in kwargs:
|
||||
del kwargs["broadcast"]
|
||||
if not self.public_key:
|
||||
self.private_key, self.public_key = create_key_pair()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def to_activity(self):
|
||||
''' override default AP serializer to add context object
|
||||
idk if this is the best way to go about this '''
|
||||
"""override default AP serializer to add context object
|
||||
idk if this is the best way to go about this"""
|
||||
activity_object = super().to_activity()
|
||||
del activity_object['@context']
|
||||
del activity_object['type']
|
||||
del activity_object["@context"]
|
||||
del activity_object["type"]
|
||||
return activity_object
|
||||
|
||||
|
||||
class AnnualGoal(BookWyrmModel):
|
||||
''' set a goal for how many books you read in a year '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
goal = models.IntegerField(
|
||||
validators=[MinValueValidator(1)]
|
||||
)
|
||||
""" set a goal for how many books you read in a year """
|
||||
|
||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||
goal = models.IntegerField(validators=[MinValueValidator(1)])
|
||||
year = models.IntegerField(default=timezone.now().year)
|
||||
privacy = models.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
choices=fields.PrivacyLevels.choices
|
||||
max_length=255, default="public", choices=fields.PrivacyLevels.choices
|
||||
)
|
||||
|
||||
class Meta:
|
||||
''' unqiueness constraint '''
|
||||
unique_together = ('user', 'year')
|
||||
""" unqiueness constraint """
|
||||
|
||||
unique_together = ("user", "year")
|
||||
|
||||
def get_remote_id(self):
|
||||
''' put the year in the path '''
|
||||
return '%s/goal/%d' % (self.user.remote_id, self.year)
|
||||
""" put the year in the path """
|
||||
return "%s/goal/%d" % (self.user.remote_id, self.year)
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
''' the books you've read this year '''
|
||||
return self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year
|
||||
).order_by('-finish_date').all()
|
||||
|
||||
""" the books you've read this year """
|
||||
return (
|
||||
self.user.readthrough_set.filter(finish_date__year__gte=self.year)
|
||||
.order_by("-finish_date")
|
||||
.all()
|
||||
)
|
||||
|
||||
@property
|
||||
def ratings(self):
|
||||
''' ratings for books read this year '''
|
||||
""" ratings for books read this year """
|
||||
book_ids = [r.book.id for r in self.books]
|
||||
reviews = Review.objects.filter(
|
||||
user=self.user,
|
||||
@ -308,55 +332,50 @@ class AnnualGoal(BookWyrmModel):
|
||||
)
|
||||
return {r.book.id: r.rating for r in reviews}
|
||||
|
||||
|
||||
@property
|
||||
def progress_percent(self):
|
||||
''' how close to your goal, in percent form '''
|
||||
""" how close to your goal, in percent form """
|
||||
return int(float(self.book_count / self.goal) * 100)
|
||||
|
||||
|
||||
@property
|
||||
def book_count(self):
|
||||
''' how many books you've read this year '''
|
||||
""" how many books you've read this year """
|
||||
return self.user.readthrough_set.filter(
|
||||
finish_date__year__gte=self.year).count()
|
||||
finish_date__year__gte=self.year
|
||||
).count()
|
||||
|
||||
|
||||
@app.task
|
||||
def set_remote_server(user_id):
|
||||
''' figure out the user's remote server in the background '''
|
||||
""" figure out the user's remote server in the background """
|
||||
user = User.objects.get(id=user_id)
|
||||
actor_parts = urlparse(user.remote_id)
|
||||
user.federated_server = \
|
||||
get_or_create_remote_server(actor_parts.netloc)
|
||||
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
|
||||
user.save(broadcast=False)
|
||||
if user.bookwyrm_user:
|
||||
get_remote_reviews.delay(user.outbox)
|
||||
|
||||
|
||||
def get_or_create_remote_server(domain):
|
||||
''' get info on a remote server '''
|
||||
""" get info on a remote server """
|
||||
try:
|
||||
return FederatedServer.objects.get(
|
||||
server_name=domain
|
||||
)
|
||||
return FederatedServer.objects.get(server_name=domain)
|
||||
except FederatedServer.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
data = get_data('https://%s/.well-known/nodeinfo' % domain)
|
||||
data = get_data("https://%s/.well-known/nodeinfo" % domain)
|
||||
try:
|
||||
nodeinfo_url = data.get('links')[0].get('href')
|
||||
nodeinfo_url = data.get("links")[0].get("href")
|
||||
except (TypeError, KeyError):
|
||||
raise ConnectorException()
|
||||
|
||||
data = get_data(nodeinfo_url)
|
||||
application_type = data.get('software', {}).get('name')
|
||||
application_version = data.get('software', {}).get('version')
|
||||
application_type = data.get("software", {}).get("name")
|
||||
application_version = data.get("software", {}).get("version")
|
||||
except ConnectorException:
|
||||
application_type = application_version = None
|
||||
|
||||
|
||||
server = FederatedServer.objects.create(
|
||||
server_name=domain,
|
||||
application_type=application_type,
|
||||
@ -367,12 +386,12 @@ def get_or_create_remote_server(domain):
|
||||
|
||||
@app.task
|
||||
def get_remote_reviews(outbox):
|
||||
''' ingest reviews by a new remote bookwyrm user '''
|
||||
outbox_page = outbox + '?page=true&type=Review'
|
||||
""" ingest reviews by a new remote bookwyrm user """
|
||||
outbox_page = outbox + "?page=true&type=Review"
|
||||
data = get_data(outbox_page)
|
||||
|
||||
# TODO: pagination?
|
||||
for activity in data['orderedItems']:
|
||||
if not activity['type'] == 'Review':
|
||||
for activity in data["orderedItems"]:
|
||||
if not activity["type"] == "Review":
|
||||
continue
|
||||
activitypub.Review(**activity).to_model()
|
||||
|
Reference in New Issue
Block a user