Merge branch 'main' into 253-user-post-privacy-v2

This commit is contained in:
Mouse Reeve
2021-06-14 16:47:57 -07:00
committed by GitHub
555 changed files with 49280 additions and 14114 deletions

View File

@ -1,4 +1,4 @@
''' bring all the models into the app namespace '''
""" bring all the models into the app namespace """
import inspect
import sys
@ -9,26 +9,31 @@ from .connector import Connector
from .shelf import Shelf, ShelfBook
from .list import List, ListItem
from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Status, GeneratedNote, Comment, Quotation
from .status import Review, ReviewRating
from .status import Boost
from .attachment import Image
from .favorite import Favorite
from .notification import Notification
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
from .tag import Tag, UserTag
from .user import User, KeyPair, AnnualGoal
from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment
from .federated_server import FederatedServer
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite, PasswordReset
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
from .announcement import Announcement
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {c[1].activity_serializer.__name__: c[1] \
for c in cls_members if hasattr(c[1], 'activity_serializer')}
activity_models = {
c[1].activity_serializer.__name__: c[1]
for c in cls_members
if hasattr(c[1], "activity_serializer")
}
status_models = [
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)
]

View File

@ -1,11 +1,13 @@
''' activitypub model functionality '''
""" activitypub model functionality """
from base64 import b64encode
from collections import namedtuple
from functools import reduce
import json
import operator
import logging
from uuid import uuid4
import requests
from requests.exceptions import HTTPError, SSLError
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
@ -24,19 +26,29 @@ from bookwyrm.models.fields import ImageField, ManyToManyField
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!
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
def set_activity_from_property_field(activity, obj, field):
"""assign a model property value to the activity json"""
activity[field[1]] = getattr(obj, field[0])
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):
@ -47,33 +59,47 @@ 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
)
if hasattr(self, "property_fields"):
self.activity_fields += [
# pylint: disable=cell-var-from-loop
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
for f in self.property_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())
@ -81,9 +107,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
@ -91,94 +117,100 @@ 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 []]
recipients = [u.inbox for u in mentions or [] if not u.local]
# 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,
queryset = (
user_model.viewer_aware_objects(user)
.filter(
local=False,
)
.distinct()
)
# 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
return list(set(recipients))
def to_activity(self):
''' convert from a model to an activity '''
def to_activity_dataclass(self):
"""convert from a model to an activity"""
activity = generate_activity(self)
return self.activity_serializer(**activity).serialize()
return self.activity_serializer(**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)
# this bonus kwarg woul cause an error in the base save method
if 'broadcast' in kwargs:
del kwargs['broadcast']
"""broadcast created/updated/deleted objects as appropriate"""
broadcast = kwargs.get("broadcast", True)
# this bonus kwarg would cause an error in the base save method
if "broadcast" in kwargs:
del kwargs["broadcast"]
created = created or not bool(self.id)
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
if not broadcast:
if not broadcast or (
hasattr(self, "status_type") and self.status_type == "Announce"
):
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
@ -187,15 +219,15 @@ class ObjectMixin(ActivitypubMixin):
try:
software = None
# do we have a "pure" activitypub version of this for mastodon?
if hasattr(self, 'pure_content'):
# do we have a "pure" activitypub version of this for mastodon?
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)
except KeyError:
except AttributeError:
# janky as heck, this catches the mutliple inheritence chain
# for boosts and ignores this auxilliary broadcast
return
@ -204,94 +236,91 @@ 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 '''
activity_object = self.to_activity(**kwargs)
"""returns the object wrapped in a Create activity"""
activity_object = self.to_activity_dataclass(**kwargs)
signature = None
create_id = self.remote_id + '/activity'
if 'content' in activity_object 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')))
content = activity_object.content
signed_message = signer.sign(SHA256.new(content.encode("utf8")))
signature = activitypub.Signature(
creator='%s#main-key' % user.remote_id,
created=activity_object['published'],
signatureValue=b64encode(signed_message).decode('utf8')
creator="%s#main-key" % user.remote_id,
created=activity_object.published,
signatureValue=b64encode(signed_message).decode("utf8"),
)
return activitypub.Create(
id=create_id,
actor=user.remote_id,
to=activity_object['to'],
cc=activity_object['cc'],
to=activity_object.to,
cc=activity_object.cc,
object=activity_object,
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'],
object=self.to_activity(),
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())
"""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.to_activity()
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:
@ -300,157 +329,188 @@ 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).serialize()
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
def to_activity(self, **kwargs):
''' an ordered collection of the specified model queryset '''
def to_activity_dataclass(self, **kwargs):
return self.to_ordered_collection(self.collection_queryset, **kwargs)
def to_activity(self, **kwargs):
"""an ordered collection of the specified model queryset"""
return self.to_ordered_collection(
self.collection_queryset, **kwargs
).serialize()
class CollectionItemMixin(ActivitypubMixin):
''' for items that are part of an (Ordered)Collection '''
activity_serializer = activitypub.Add
object_field = collection_field = None
"""for items that are part of an (Ordered)Collection"""
activity_serializer = activitypub.CollectionItem
def broadcast(self, activity, sender, software="bookwyrm"):
"""only send book collection updates to other bookwyrm instances"""
super().broadcast(activity, sender, software=software)
@property
def privacy(self):
"""inherit the privacy of the list, or direct if pending"""
collection_field = getattr(self, self.collection_field)
if self.approved:
return collection_field.privacy
return "direct"
@property
def recipients(self):
"""the owner of the list is a direct recipient"""
collection_field = getattr(self, self.collection_field)
if collection_field.user.local:
# don't broadcast to yourself
return []
return [collection_field.user]
def save(self, *args, broadcast=True, **kwargs):
''' broadcast updated '''
created = not bool(self.id)
"""broadcast updated"""
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
# these shouldn't be edited, only created and deleted
if not broadcast or not created or not self.user.local:
# list items can be updateda, normally you would only broadcast on created
if not broadcast or not self.user.local:
return
# adding an obj to the collection
activity = self.to_add_activity()
activity = self.to_add_activity(self.user)
self.broadcast(activity, self.user)
def delete(self, *args, **kwargs):
''' broadcast a remove activity '''
activity = self.to_remove_activity()
def delete(self, *args, broadcast=True, **kwargs):
"""broadcast a remove activity"""
activity = self.to_remove_activity(self.user)
super().delete(*args, **kwargs)
self.broadcast(activity, self.user)
if self.user.local and broadcast:
self.broadcast(activity, self.user)
def to_add_activity(self):
''' AP for shelving a book'''
object_field = getattr(self, self.object_field)
def to_add_activity(self, user):
"""AP for shelving a book"""
collection_field = getattr(self, self.collection_field)
return activitypub.Add(
id='%s#add' % self.remote_id,
actor=self.user.remote_id,
object=object_field.to_activity(),
target=collection_field.remote_id
id="{:s}#add".format(collection_field.remote_id),
actor=user.remote_id,
object=self.to_activity_dataclass(),
target=collection_field.remote_id,
).serialize()
def to_remove_activity(self):
''' AP for un-shelving a book'''
object_field = getattr(self, self.object_field)
def to_remove_activity(self, user):
"""AP for un-shelving a book"""
collection_field = getattr(self, self.collection_field)
return activitypub.Remove(
id='%s#remove' % self.remote_id,
actor=self.user.remote_id,
object=object_field.to_activity(),
target=collection_field.remote_id
id="{:s}#remove".format(collection_field.remote_id),
actor=user.remote_id,
object=self.to_activity_dataclass(),
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.to_activity()
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=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 sort_field and 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()
# if it's a one-to-one (key pair)
if hasattr(related_field, "field_to_activity"):
return related_field.field_to_activity()
# if it's one-to-many (attachments)
return related_field.to_activity()
return related_field.remote_id
@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 requests.exceptions.HTTPError as e:
logger.exception(e)
except (HTTPError, SSLError, requests.exceptions.ConnectionError):
pass
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)
@ -458,11 +518,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:
@ -472,26 +532,26 @@ def sign_and_send(sender, data, destination):
# pylint: disable=unused-argument
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, **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)
activity_page = paginated.get_page(page)
if id_only:
items = [s.remote_id for s in activity_page.object_list]
else:
items = [s.to_activity() for s in activity_page.object_list]
items = [s.to_activity(pure=pure) for s in activity_page.object_list]
prev_page = next_page = None
if activity_page.has_next():
next_page = '%s?page=%d' % (remote_id, activity_page.next_page_number())
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
).serialize()
prev=prev_page,
)

View File

@ -0,0 +1,28 @@
""" admin announcements """
from django.db import models
from django.db.models import Q
from django.utils import timezone
from .base_model import BookWyrmModel
class Announcement(BookWyrmModel):
"""The admin has something to say"""
user = models.ForeignKey("User", on_delete=models.PROTECT)
preview = models.CharField(max_length=255)
content = models.TextField(null=True, blank=True)
event_date = models.DateTimeField(blank=True, null=True)
start_date = models.DateTimeField(blank=True, null=True)
end_date = models.DateTimeField(blank=True, null=True)
active = models.BooleanField(default=True)
@classmethod
def active_announcements(cls):
"""announcements that should be displayed"""
now = timezone.now()
return cls.objects.filter(
Q(start_date__isnull=True) | Q(start_date__lte=now),
Q(end_date__isnull=True) | Q(end_date__gte=now),
active=True,
)

View File

@ -1,4 +1,4 @@
''' media that is posted in the app '''
""" media that is posted in the app """
from django.db import models
from bookwyrm import activitypub
@ -8,23 +8,29 @@ 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 '''
image = fields.ImageField(
upload_to='status/', null=True, blank=True, activitypub_field='url')
caption = fields.TextField(null=True, blank=True, activitypub_field='name')
"""an image attachment"""
activity_serializer = activitypub.Image
image = fields.ImageField(
upload_to="status/",
null=True,
blank=True,
activitypub_field="url",
alt_field="caption",
)
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
activity_serializer = activitypub.Document

View File

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

View File

@ -1,4 +1,4 @@
''' base model with default fields '''
""" base model with default fields """
from django.db import models
from django.dispatch import receiver
@ -6,35 +6,77 @@ from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
DeactivationReason = models.TextChoices(
"DeactivationReason",
[
"self_deletion",
"moderator_deletion",
"domain_block",
],
)
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, "")
def visible_to_user(self, viewer):
"""is a user authorized to view an object?"""
# make sure this is an object with privacy owned by a user
if not hasattr(self, "user") or not hasattr(self, "privacy"):
return None
# viewer can't see it if the object's owner blocked them
if viewer in self.user.blocks.all():
return False
# you can see your own posts and any public or unlisted posts
if viewer == self.user or self.privacy in ["public", "unlisted"]:
return True
# you can see the followers only posts of people you follow
if (
self.privacy == "followers"
and self.user.followers.filter(id=viewer.id).first()
):
return True
# you can see dms you are tagged in
if hasattr(self, "mention_users"):
if (
self.privacy == "direct"
and self.mention_users.filter(id=viewer.id).first()
):
return True
return False
@receiver(models.signals.post_save)
#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'):
# pylint: disable=unused-argument
def set_remote_id(sender, instance, created, *args, **kwargs):
"""set the remote_id after save (when the id is available)"""
if not created or not hasattr(instance, "get_remote_id"):
return
if not instance.remote_id:
instance.remote_id = instance.get_remote_id()

View File

@ -1,35 +1,50 @@
''' database schema for books and shelves '''
""" database schema for books and shelves """
import re
from django.db import models
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE
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
)
inventaire_id = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True
)
librarything_key = fields.CharField(
max_length=255, blank=True, null=True, deduplication_field=True)
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
)
bnf_id = fields.CharField( # Bibliothèque nationale de France
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 = fields.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,21 +52,25 @@ 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)
title = fields.TextField(max_length=255)
sort_title = fields.CharField(max_length=255, blank=True, null=True)
subtitle = fields.CharField(max_length=255, blank=True, null=True)
subtitle = fields.TextField(max_length=255, blank=True, null=True)
description = fields.HtmlField(blank=True, null=True)
languages = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
series = fields.CharField(max_length=255, blank=True, null=True)
series = fields.TextField(max_length=255, blank=True, null=True)
series_number = fields.CharField(max_length=255, blank=True, null=True)
subjects = fields.ArrayField(
models.CharField(max_length=255), blank=True, null=True, default=list
@ -59,9 +78,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 +89,44 @@ 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,
", ".join(self.publishers) if hasattr(self, "publishers") 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,81 +137,91 @@ 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)
# this has to be nullable but should never be null
default_edition = fields.ForeignKey(
'Edition',
on_delete=models.PROTECT,
null=True,
load_remote=False
max_length=255, blank=True, null=True, deduplication_field=True
)
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()
@property
def default_edition(self):
"""in case the default edition is not set"""
return 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,
**kwargs
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 '''
if self.parent_work and self.parent_work.default_edition == self:
# default edition has the highest rank
return 20
"""calculate how complete the data is on this edition"""
rank = 0
# big ups for havinga cover
rank += int(bool(self.cover)) * 3
# is it in the instance's preferred language?
rank += int(bool(DEFAULT_LANGUAGE in self.languages))
# prefer print editions
if self.physical_format:
rank += int(
bool(self.physical_format.lower() in ["paperback", "hardcover"])
)
# does it have metadata?
rank += int(bool(self.isbn_13))
rank += int(bool(self.isbn_10))
rank += int(bool(self.oclc_number))
@ -200,13 +232,19 @@ 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)
# normalize isbn format
if self.isbn_10:
self.isbn_10 = re.sub(r"[^0-9X]", "", self.isbn_10)
if self.isbn_13:
self.isbn_13 = re.sub(r"[^0-9X]", "", self.isbn_13)
# set rank
self.edition_rank = self.get_rank()
@ -214,17 +252,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 +274,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 +291,5 @@ def isbn_13_to_10(isbn_13):
checkdigit = checksum % 11
checkdigit = 11 - checkdigit
if checkdigit == 10:
checkdigit = 'X'
checkdigit = "X"
return converted + str(checkdigit)

View File

@ -1,43 +1,32 @@
''' 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
from .base_model import BookWyrmModel, DeactivationReason
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)
active = models.BooleanField(default=True)
deactivation_reason = models.CharField(
max_length=255, choices=DeactivationReason.choices, 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)
politeness_delay = models.IntegerField(null=True, blank=True) #seconds
max_query_count = models.IntegerField(null=True, blank=True)
# how many queries executed in a unit of time, like a day
query_count = models.IntegerField(default=0)
# when to reset the query count back to 0 (ie, after 1 day)
query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True)
class Meta:
''' check that there's code to actually use this connector '''
constraints = [
models.CheckConstraint(
check=models.Q(connector_file__in=ConnectorFiles),
name='connector_file_valid'
)
]
isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
def __str__(self):
return "{} ({})".format(

View File

@ -1,4 +1,4 @@
''' like/fav/star a status '''
""" like/fav/star a status """
from django.apps import apps
from django.db import models
from django.utils import timezone
@ -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")

View File

@ -1,15 +1,68 @@
''' connections to external ActivityPub servers '''
""" connections to external ActivityPub servers """
from urllib.parse import urlparse
from django.apps import apps
from django.db import models
from .base_model import BookWyrmModel
FederationStatus = models.TextChoices(
"Status",
[
"federated",
"blocked",
],
)
class FederatedServer(BookWyrmModel):
''' store which server's we federate with '''
server_name = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else
status = models.CharField(max_length=255, default='federated')
# is it mastodon, bookwyrm, etc
application_type = models.CharField(max_length=255, null=True)
application_version = models.CharField(max_length=255, null=True)
"""store which servers we federate with"""
# TODO: blocked servers
server_name = models.CharField(max_length=255, unique=True)
status = models.CharField(
max_length=255, default="federated", choices=FederationStatus.choices
)
# is it mastodon, bookwyrm, etc
application_type = models.CharField(max_length=255, null=True, blank=True)
application_version = models.CharField(max_length=255, null=True, blank=True)
notes = models.TextField(null=True, blank=True)
def block(self):
"""block a server"""
self.status = "blocked"
self.save()
# deactivate all associated users
self.user_set.filter(is_active=True).update(
is_active=False, deactivation_reason="domain_block"
)
# check for related connectors
if self.application_type == "bookwyrm":
connector_model = apps.get_model("bookwyrm.Connector", require_ready=True)
connector_model.objects.filter(
identifier=self.server_name, active=True
).update(active=False, deactivation_reason="domain_block")
def unblock(self):
"""unblock a server"""
self.status = "federated"
self.save()
self.user_set.filter(deactivation_reason="domain_block").update(
is_active=True, deactivation_reason=None
)
# check for related connectors
if self.application_type == "bookwyrm":
connector_model = apps.get_model("bookwyrm.Connector", require_ready=True)
connector_model.objects.filter(
identifier=self.server_name,
active=False,
deactivation_reason="domain_block",
).update(active=True, deactivation_reason=None)
@classmethod
def is_blocked(cls, url):
"""look up if a domain is blocked"""
url = urlparse(url)
domain = url.netloc
return cls.objects.filter(server_name=domain, status="blocked").exists()

View File

@ -1,5 +1,6 @@
''' activitypub-aware django model fields '''
""" activitypub-aware django model fields """
from dataclasses import MISSING
import imghdr
import re
from uuid import uuid4
@ -9,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
from django.forms import ClearableFileInput, ImageField as DjangoImageField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
@ -18,37 +20,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 +65,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:
if formatted is None or formatted is MISSING or formatted == {}:
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 +88,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 value and 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,13 +128,12 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
return None
related_model = self.related_model
if isinstance(value, dict) and value.get('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)
return related_model.find_existing(value.serialize())
# this is an activitypub object, which we can deserialize
activity_serializer = related_model.activity_serializer
return activity_serializer(**value).to_model(related_model)
return value.to_model(model=related_model)
try:
# make sure the value looks like a remote id
validate_remote_id(value)
@ -139,103 +144,102 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
if not self.load_remote:
# only look in the local database
return related_model.find_existing_by_remote_id(value)
return activitypub.resolve_remote_id(related_model, value)
return activitypub.resolve_remote_id(value, model=related_model)
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
@ -243,7 +247,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
@ -251,13 +256,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:
@ -267,41 +273,47 @@ 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):
items = []
if value is None or value is MISSING:
return []
return None
if not isinstance(value, list):
# If this is a link, we currently aren't doing anything with it
return None
items = []
for remote_id in value:
try:
validate_remote_id(remote_id)
except ValidationError:
continue
items.append(
activitypub.resolve_remote_id(self.related_model, remote_id)
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,37 +322,50 @@ 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(self.related_model, link.href)
activitypub.resolve_remote_id(link.href, model=self.related_model)
)
return items
class ClearableFileInputWithWarning(ClearableFileInput):
"""max file size warning"""
template_name = "widgets/clearable_file_input_with_warning.html"
class CustomImageField(DjangoImageField):
"""overwrites image field for form"""
widget = ClearableFileInputWithWarning
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)
return activitypub.Image(url=url, name=alt)
url = "https://%s%s" % (DOMAIN, url)
return activitypub.Document(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:
@ -357,17 +382,15 @@ 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 isinstance(image_slug, dict):
url = image_slug.get('url')
if hasattr(image_slug, "url"):
url = image_slug.url
elif isinstance(image_slug, str):
url = image_slug
else:
@ -382,13 +405,23 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if not response:
return None
image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_content = ContentFile(response.content)
image_name = str(uuid4()) + "." + imghdr.what(None, image_content.read())
return [image_name, image_content]
def formfield(self, **kwargs):
"""special case for forms"""
return super().formfield(
**{
"form_class": CustomImageField,
**kwargs,
}
)
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
''' activitypub-aware datetime field '''
"""activitypub-aware datetime field"""
def field_to_activity(self, value):
if not value:
return None
@ -404,8 +437,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
@ -413,19 +448,34 @@ 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"""
class DecimalField(ActivitypubFieldMixin, models.DecimalField):
"""activitypub-aware boolean field"""
def field_to_activity(self, value):
if not value:
return None
return float(value)

View File

@ -1,9 +1,8 @@
''' track progress of goodreads imports '''
""" track progress of goodreads imports """
import re
import dateutil.parser
from django.apps import apps
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.utils import timezone
@ -14,13 +13,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 +28,62 @@ 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)
data = models.JSONField()
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
fail_reason = models.TextField(null=True)
def resolve(self):
''' try various ways to lookup a book '''
self.book = (
self.get_book_from_isbn() or
"""try various ways to lookup a book"""
if self.isbn:
self.book = self.get_book_from_isbn()
else:
# don't fall back on title/author search is isbn is present.
# you're too likely to mismatch
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 +92,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.data['Title'],
self.data['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,70 +103,87 @@ 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"""
if self.data.get("My Rating", None):
return int(self.data["My Rating"])
return None
@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"]))
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 '''
if (self.shelf == 'reading'
and self.date_added and not self.date_read):
return [ReadThrough(start_date=self.date_added)]
"""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
):
start_date = self.date_added
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=self.date_added,
finish_date=self.date_read,
)]
return [
ReadThrough(
start_date=start_date,
finish_date=self.date_read,
)
]
return []
def __repr__(self):
return "<GoodreadsItem {!r}>".format(self.data['Title'])
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
def __str__(self):
return "{} by {}".format(self.data['Title'], self.data['Author'])
return "{} by {}".format(self.data["Title"], self.data["Author"])

View File

@ -1,6 +1,7 @@
''' make a list of books!! '''
""" make a list of books!! """
from django.apps import apps
from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
@ -9,86 +10,90 @@ 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).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')
book_list = fields.ForeignKey(
'List', on_delete=models.CASCADE, activitypub_field='target')
"Edition", on_delete=models.PROTECT, activitypub_field="book"
)
book_list = models.ForeignKey("List", on_delete=models.CASCADE)
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')
order = fields.IntegerField()
endorsement = models.ManyToManyField("User", related_name="endorsers")
activity_serializer = activitypub.AddListItem
object_field = 'book'
collection_field = 'book_list'
activity_serializer = activitypub.ListItem
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)
# tick the updated date on the parent list
self.book_list.updated_date = timezone.now()
self.book_list.save(broadcast=False)
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',)
# A book may only be placed into a list once, and each order in the list may be used only
# once
unique_together = (("book", "book_list"), ("order", "book_list"))
ordering = ("-created_date",)

View File

@ -1,47 +1,52 @@
''' 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 REPORT",
)
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
)
related_report = models.ForeignKey("Report", 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,
related_report=self.related_report,
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),

View File

@ -1,58 +1,59 @@
''' 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'
"""types of prgress available"""
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)
def create_update(self):
"""add update to the readthrough"""
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
)
return None
class ProgressUpdate(BookWyrmModel):
''' Store progress through a book in the database. '''
user = models.ForeignKey('User', on_delete=models.PROTECT)
readthrough = models.ForeignKey('ReadThrough', on_delete=models.CASCADE)
"""Store progress through a book in the database."""
user = models.ForeignKey("User", on_delete=models.PROTECT)
readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
progress = models.IntegerField(validators=[validators.MinValueValidator(0)])
mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE)
max_length=3, choices=ProgressMode.choices, default=ProgressMode.PAGE
)
def save(self, *args, **kwargs):
''' update user active time '''
"""update user active time"""
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)

View File

@ -1,70 +1,91 @@
''' defines relationships between users '''
""" defines relationships between users """
from django.apps import apps
from django.db import models, transaction
from django.db import models, transaction, IntegrityError
from django.db.models import Q
from django.dispatch import receiver
from bookwyrm import activitypub
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import generate_activity
from .base_model import BookWyrmModel
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):
"""use shelf identifier in remote_id"""
base_path = self.user_subject.remote_id
return '%s#%s/%d' % (base_path, status, self.id)
return "%s#follows/%d" % (base_path, self.id)
class UserFollows(ActivitypubMixin, UserRelationship):
''' Following a user '''
status = 'follows'
activity_serializer = activitypub.Follow
class UserFollows(ActivityMixin, UserRelationship):
"""Following a user"""
status = "follows"
def to_activity(self): # pylint: disable=arguments-differ
"""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"""
# 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():
raise IntegrityError()
# don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs)
@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,
@ -73,90 +94,103 @@ class UserFollows(ActivitypubMixin, 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 '''
try:
UserFollows.objects.get(
"""make sure the follow or block relationship doesn't already exist"""
# if there's a request for a follow that already exists, accept it
# without changing the local database state
if UserFollows.objects.filter(
user_subject=self.user_subject,
user_object=self.user_object,
).exists():
self.accept(broadcast_only=True)
return
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.user_subject,
user_object=self.user_object,
)
# blocking in either direction is a no-go
UserBlocks.objects.get(
user_subject=self.user_subject,
user_object=self.user_object,
)
UserBlocks.objects.get(
| Q(
user_subject=self.user_object,
user_object=self.user_subject,
)
return None
except (UserFollows.DoesNotExist, UserBlocks.DoesNotExist):
super().save(*args, **kwargs)
).exists():
raise IntegrityError()
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
self.broadcast(self.to_activity(), self.user_subject)
if self.user_object.local:
model = apps.get_model('bookwyrm.Notification', require_ready=True)
notification_type = 'FOLLOW_REQUEST' \
if self.user_object.manually_approves_followers else 'FOLLOW'
manually_approves = self.user_object.manually_approves_followers
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.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
def get_accept_reject_id(self, status):
"""get id for sending an accept or reject of a local user"""
def accept(self):
''' turn this request into the real deal'''
base_path = self.user_object.remote_id
return "%s#%s/%d" % (base_path, status, self.id or 0)
def accept(self, broadcast_only=False):
"""turn this request into the real deal"""
user = self.user_object
activity = activitypub.Accept(
id=self.get_remote_id(status='accepts'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
if not self.user_subject.local:
activity = activitypub.Accept(
id=self.get_accept_reject_id(status="accepts"),
actor=self.user_object.remote_id,
object=self.to_activity(),
).serialize()
self.broadcast(activity, user)
if broadcast_only:
return
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
self.broadcast(activity, user)
def reject(self):
''' generate a Reject for this follow request '''
user = self.user_object
activity = activitypub.Reject(
id=self.get_remote_id(status='rejects'),
actor=self.user_object.remote_id,
object=self.to_activity()
).serialize()
"""generate a Reject for this follow request"""
if self.user_object.local:
activity = activitypub.Reject(
id=self.get_accept_reject_id(status="rejects"),
actor=self.user_object.remote_id,
object=self.to_activity(),
).serialize()
self.broadcast(activity, self.user_object)
self.delete()
self.broadcast(activity, user)
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"""
super().save(*args, **kwargs)
@receiver(models.signals.post_save, sender=UserBlocks)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' remove follow or follow request rels after a block is created '''
UserFollows.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
).delete()
UserFollowRequest.objects.filter(
Q(user_subject=instance.user_subject,
user_object=instance.user_object) | \
Q(user_subject=instance.user_object,
user_object=instance.user_subject)
).delete()
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)
).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)
).delete()

55
bookwyrm/models/report.py Normal file
View File

@ -0,0 +1,55 @@
""" flagged for moderation """
from django.apps import apps
from django.db import models
from django.db.models import F, Q
from .base_model import BookWyrmModel
class Report(BookWyrmModel):
"""reported status or user"""
reporter = models.ForeignKey(
"User", related_name="reporter", on_delete=models.PROTECT
)
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
statuses = models.ManyToManyField("Status", blank=True)
resolved = models.BooleanField(default=False)
def save(self, *args, **kwargs):
"""notify admins when a report is created"""
super().save(*args, **kwargs)
user_model = apps.get_model("bookwyrm.User", require_ready=True)
# moderators and superusers should be notified
admins = user_model.objects.filter(
Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| Q(is_superuser=True)
).all()
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
for admin in admins:
notification_model.objects.create(
user=admin,
related_report=self,
notification_type="REPORT",
)
class Meta:
"""don't let users report themselves"""
constraints = [
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
]
ordering = ("-created_date",)
class ReportComment(BookWyrmModel):
"""updates on a report"""
user = models.ForeignKey("User", on_delete=models.PROTECT)
note = models.TextField()
report = models.ForeignKey(Report, on_delete=models.PROTECT)
class Meta:
"""sort comments"""
ordering = ("-created_date",)

View File

@ -1,4 +1,4 @@
''' puttin' books on shelves '''
""" puttin' books on shelves """
import re
from django.db import models
@ -9,61 +9,81 @@ from . import fields
class Shelf(OrderedCollectionMixin, BookWyrmModel):
''' a list of books owned by a user '''
"""a list of books owned by a user"""
TO_READ = "to-read"
READING = "reading"
READ_FINISHED = "read"
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
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)
super().save(*args, **kwargs)
self.identifier = self.get_identifier()
super().save(*args, **kwargs, broadcast=False)
def get_identifier(self):
"""custom-shelf-123 for the url"""
slug = re.sub(r"[^\w]", "", self.name).lower()
return "{:s}-{:d}".format(slug, self.id)
@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.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)
identifier = self.identifier or self.get_identifier()
return "%s/books/%s" % (base_path, 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')
shelf = fields.ForeignKey(
'Shelf', on_delete=models.PROTECT, activitypub_field='target')
"Edition", on_delete=models.PROTECT, activitypub_field="book"
)
shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT)
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
"User", on_delete=models.PROTECT, activitypub_field="actor"
)
activity_serializer = activitypub.AddBook
object_field = 'book'
collection_field = 'shelf'
activity_serializer = activitypub.ShelfItem
collection_field = "shelf"
def save(self, *args, **kwargs):
if not self.user:
self.user = self.shelf.user
super().save(*args, **kwargs)
class Meta:
''' an opinionated constraint!
you can't put a book on shelf twice '''
unique_together = ('book', 'shelf')
ordering = ('-created_date',)
"""an opinionated constraint!
you can't put a book on shelf twice"""
unique_together = ("book", "shelf")
ordering = ("-created_date",)

View File

@ -1,42 +1,50 @@
''' the particulars for this instance of BookWyrm '''
""" the particulars for this instance of BookWyrm """
import base64
import datetime
from Crypto import Random
from django.db import models
from django.db import models, IntegrityError
from django.utils import timezone
from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
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.")
# about page
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.')
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.")
# registration
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
)
allow_invite_requests = models.BooleanField(default=True)
# images
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)
# footer
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)
footer_item = models.TextField(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:
@ -44,48 +52,70 @@ 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)
use_limit = models.IntegerField(blank=True, null=True)
times_used = models.IntegerField(default=0)
user = models.ForeignKey(User, on_delete=models.CASCADE)
invitees = models.ManyToManyField(User, related_name="invitees")
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)
class InviteRequest(BookWyrmModel):
"""prospective users can request an invite"""
email = models.EmailField(max_length=255, unique=True)
invite = models.ForeignKey(
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
)
invite_sent = models.BooleanField(default=False)
ignored = models.BooleanField(default=False)
def save(self, *args, **kwargs):
"""don't create a request for a registered email"""
if not self.id and User.objects.filter(email=self.email).exists():
raise IntegrityError()
super().save(*args, **kwargs)
def get_passowrd_reset_expiry():
''' give people a limited time to use the link '''
"""give people a limited time to use the link"""
now = timezone.now()
return now + datetime.timedelta(days=1)
class PasswordReset(models.Model):
''' gives someone access to create an account on the instance '''
"""gives someone access to create an account on the instance"""
code = models.CharField(max_length=32, default=new_access_code)
expiry = models.DateTimeField(default=get_passowrd_reset_expiry)
user = models.OneToOneField(User, on_delete=models.CASCADE)
def valid(self):
''' make sure it hasn't expired or been used '''
"""make sure it hasn't expired or been used"""
return self.expiry > timezone.now()
@property
def link(self):
''' formats the invite link '''
return 'https://{}/password-reset/{}'.format(DOMAIN, self.code)
"""formats the invite link"""
return "https://{}/password-reset/{}".format(DOMAIN, self.code)

View File

@ -1,10 +1,11 @@
''' models for storing different kinds of Activities '''
""" models for storing different kinds of Activities """
from dataclasses import MISSING
import re
from django.apps import apps
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.template.loader import get_template
from django.utils import timezone
from model_utils.managers import InheritanceManager
@ -13,204 +14,269 @@ from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
from .fields import image_serializer
from .readthrough import ProgressMode
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")]
class Meta:
"""default sorting"""
ordering = ("-published_date",)
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()
return
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"):
# okay but if it's a boost really delete it
super().delete(*args, **kwargs)
return
self.deleted = True
self.deleted_date = timezone.now()
self.save()
@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 != 'Note':
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
try:
boosted = activitypub.resolve_remote_id(
activity.object, get_activity=True
)
except activitypub.ActivitySerializerError:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
return cls.ignore_activity(boosted)
# keep if it if it's a custom type
if activity.type != "Note":
return False
if cls.objects.filter(
remote_id=activity.inReplyTo).exists():
# keep it if it's a reply to an existing status
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']
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:
user_model = apps.get_model('bookwyrm.User', require_ready=True)
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(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()
).serialize()
activity = ActivitypubMixin.to_activity(self)
activity['replies'] = self.to_replies()
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'):
activity['content'] = self.pure_content
if 'name' in activity:
activity['name'] = self.pure_name
activity['type'] = self.pure_type
activity['attachment'] = [
image_serializer(b.cover, 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(
if pure and hasattr(self, "pure_content"):
activity.content = self.pure_content
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:
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"""
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"
)
# this is it's own field instead of a foreign key to the progress update
# so that the update can be deleted without impacting the status
progress = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)
progress_mode = models.CharField(
max_length=3,
choices=ProgressMode.choices,
default=ProgressMode.PAGE,
null=True,
blank=True,
)
@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,
@ -219,90 +285,111 @@ 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')
rating = fields.IntegerField(
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
)
rating = fields.DecimalField(
default=None,
null=True,
blank=True,
validators=[MinValueValidator(1), MaxValueValidator(5)]
validators=[MinValueValidator(1), MaxValueValidator(5)],
decimal_places=2,
max_digits=3,
)
@property
def pure_name(self):
''' clarify review names for mastodon serialization '''
if self.rating:
#pylint: disable=bad-string-format-type
return 'Review of "%s" (%d stars): %s' % (
self.book.title,
self.rating,
self.name
)
return 'Review of "%s": %s' % (
self.book.title,
self.name
)
"""clarify review names for mastodon serialization"""
template = get_template("snippets/generated_status/review_pure_name.html")
return template.render(
{"book": self.book, "rating": self.rating, "name": self.name}
).strip()
@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):
"""a subtype of review that only contains a rating"""
def save(self, *args, **kwargs):
if not self.rating:
raise ValueError("ReviewRating object must include a numerical rating")
return super().save(*args, **kwargs)
@property
def pure_content(self):
template = get_template("snippets/generated_status/rating.html")
return template.render({"book": self.book, "rating": self.rating}).strip()
activity_serializer = activitypub.Rating
pure_type = "Note"
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.Boost
activity_serializer = activitypub.Announce
def save(self, *args, **kwargs):
''' save and notify '''
super().save(*args, **kwargs)
if not self.boosted_status.user.local:
"""save and notify"""
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
if (
Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
.exclude(id=self.id)
.exists()
):
return
notification_model = apps.get_model(
'bookwyrm.Notification', require_ready=True)
super().save(*args, **kwargs)
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
return
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", "published_date", "privacy"]
self.simple_fields = [f for f in self.simple_fields if f.name in reserve_fields]
self.activity_fields = self.simple_fields
self.many_to_many_fields = []
self.image_fields = []

View File

@ -1,59 +0,0 @@
''' models for storing different kinds of Activities '''
import urllib.parse
from django.db import models
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
class Tag(OrderedCollectionMixin, BookWyrmModel):
''' freeform tags for books '''
name = fields.CharField(max_length=100, unique=True)
identifier = models.CharField(max_length=100)
@classmethod
def book_queryset(cls, identifier):
''' county of books associated with this tag '''
return cls.objects.filter(
identifier=identifier
).order_by('-updated_date')
@property
def collection_queryset(self):
''' books associated with this tag '''
return self.book_queryset(self.identifier)
def get_remote_id(self):
''' tag should use identifier not id in remote_id '''
base_path = 'https://%s' % DOMAIN
return '%s/tag/%s' % (base_path, self.identifier)
def save(self, *args, **kwargs):
''' create a url-safe lookup key for the tag '''
if not self.id:
# add identifiers to new tags
self.identifier = urllib.parse.quote_plus(self.name)
super().save(*args, **kwargs)
class UserTag(CollectionItemMixin, BookWyrmModel):
''' an instance of a tag on a book by a user '''
user = fields.ForeignKey(
'User', on_delete=models.PROTECT, activitypub_field='actor')
book = fields.ForeignKey(
'Edition', on_delete=models.PROTECT, activitypub_field='object')
tag = fields.ForeignKey(
'Tag', on_delete=models.PROTECT, activitypub_field='target')
activity_serializer = activitypub.AddBook
object_field = 'book'
collection_field = 'tag'
class Meta:
''' unqiueness constraint '''
unique_together = ('user', 'book', 'tag')

View File

@ -1,16 +1,17 @@
''' 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.contrib.postgres.fields import CICharField
from django.core.validators import MinValueValidator
from django.db import models
from django.dispatch import receiver
from django.utils import timezone
import pytz
from bookwyrm import activitypub
from bookwyrm.connectors import get_data
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
from bookwyrm.settings import DOMAIN
@ -18,39 +19,42 @@ from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from bookwyrm.utils import regex
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel
from .base_model import BookWyrmModel, DeactivationReason
from .federated_server import FederatedServer
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,
)
outbox = fields.RemoteIdField(unique=True)
outbox = fields.RemoteIdField(unique=True, null=True)
summary = fields.HtmlField(null=True, blank=True)
local = models.BooleanField(default=False)
bookwyrm_user = fields.BooleanField(default=True)
localname = models.CharField(
localname = CICharField(
max_length=255,
null=True,
unique=True,
@ -59,210 +63,320 @@ 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",
)
default_post_privacy = models.CharField(
max_length=255,
default='public',
choices=fields.PrivacyLevels.choices
)
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)
show_goal = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
default=str(pytz.utc),
max_length=255,
)
deactivation_reason = models.CharField(
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
)
name_field = "username"
property_fields = [("following_link", "following")]
@property
def following_link(self):
"""just how to find out the following info"""
return "{:s}/following".format(self.remote_id)
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
@property
def deleted(self):
"""for consistent naming"""
return not self.is_active
@property
def unread_notification_count(self):
"""count of notifications, for the templates"""
return self.notification_set.filter(read=False).count()
@property
def has_unread_mentions(self):
"""whether any of the unread notifications are conversations"""
return self.notification_set.filter(
read=False,
notification_type__in=["REPLY", "MENTION", "TAG", "REPORT"],
).exists()
activity_serializer = activitypub.Person
@classmethod
def viewer_aware_objects(cls, viewer):
"""the user queryset filtered for the context of the logged in user"""
queryset = cls.objects.filter(is_active=True)
if viewer and viewer.is_authenticated:
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)
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 '''
activity_object = super().to_activity()
activity_object['@context'] = [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
def to_activity(self, **kwargs):
"""override default AP serializer to add context object
idk if this is the best way to go about this"""
if not self.is_active:
return self.remote_id
activity_object = super().to_activity(**kwargs)
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 '''
# this user already exists, no need to populate fields
"""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)
return super().save(*args, **kwargs)
self.username = "%s@%s" % (self.username, actor_parts.netloc)
super().save(*args, **kwargs)
if self.id or not self.local:
return super().save(*args, **kwargs)
# this user already exists, no need to populate fields
if not created:
super().save(*args, **kwargs)
return
# this is a new remote user, we need to set their remote server field
if not self.local:
super().save(*args, **kwargs)
set_remote_server.delay(self.id)
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
return super().save(*args, **kwargs)
# 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
)
self.save(broadcast=False)
shelves = [
{
"name": "To Read",
"identifier": "to-read",
},
{
"name": "Currently Reading",
"identifier": "reading",
},
{
"name": "Read",
"identifier": "read",
},
]
for shelf in shelves:
Shelf(
name=shelf["name"],
identifier=shelf["identifier"],
user=self,
editable=False,
).save(broadcast=False)
def delete(self, *args, **kwargs):
"""deactivate rather than delete a user"""
self.is_active = False
# skip the logic in this class's save()
super().save(*args, **kwargs)
@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 '''
activity_object = super().to_activity()
del activity_object['@context']
del activity_object['type']
def to_activity(self, **kwargs):
"""override default AP serializer to add context object
idk if this is the best way to go about this"""
activity_object = super().to_activity(**kwargs)
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,
finish_date__year__lt=self.year + 1,
)
.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,
@ -270,102 +384,66 @@ 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 '''
return int(float(self.book_count / self.goal) * 100)
@property
def book_count(self):
''' how many books you've read this year '''
return self.user.readthrough_set.filter(
finish_date__year__gte=self.year).count()
@receiver(models.signals.post_save, sender=User)
#pylint: disable=unused-argument
def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users '''
if not created:
return
if not instance.local:
set_remote_server.delay(instance.id)
return
instance.key_pair = KeyPair.objects.create(
remote_id='%s/#main-key' % instance.remote_id)
instance.save(broadcast=False)
shelves = [{
'name': 'To Read',
'identifier': 'to-read',
}, {
'name': 'Currently Reading',
'identifier': 'reading',
}, {
'name': 'Read',
'identifier': 'read',
}]
for shelf in shelves:
Shelf(
name=shelf['name'],
identifier=shelf['identifier'],
user=instance,
editable=False
).save(broadcast=False)
def progress(self):
"""how many books you've read this year"""
count = self.user.readthrough_set.filter(
finish_date__year__gte=self.year,
finish_date__year__lt=self.year + 1,
).count()
return {
"count": count,
"percent": int(float(count / self.goal) * 100),
}
@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.save()
if user.bookwyrm_user:
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
user.save(broadcast=False)
if user.bookwyrm_user and user.outbox:
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
data = get_data('https://%s/.well-known/nodeinfo' % domain)
try:
nodeinfo_url = data.get('links')[0].get('href')
except (TypeError, KeyError):
return None
data = get_data("https://%s/.well-known/nodeinfo" % domain)
try:
nodeinfo_url = data.get("links")[0].get("href")
except (TypeError, KeyError):
raise ConnectorException()
data = get_data(nodeinfo_url)
data = get_data(nodeinfo_url)
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=data['software']['name'],
application_version=data['software']['version'],
application_type=application_type,
application_version=application_version,
)
return server
@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(Review)
activitypub.Review(**activity).to_model()