Merge branch 'main' into inventaire

This commit is contained in:
Mouse Reeve
2021-04-26 14:22:05 -07:00
280 changed files with 20693 additions and 9991 deletions

View File

@ -17,8 +17,6 @@ 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

View File

@ -1,5 +1,6 @@
""" activitypub model functionality """
from base64 import b64encode
from collections import namedtuple
from functools import reduce
import json
import operator
@ -25,14 +26,23 @@ 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"
@ -52,6 +62,12 @@ class ActivitypubMixin:
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 = (
@ -69,7 +85,7 @@ class ActivitypubMixin:
@classmethod
def find_existing_by_remote_id(cls, remote_id):
""" look up a remote id in the db """
"""look up a remote id in the db"""
return cls.find_existing({"id": remote_id})
@classmethod
@ -110,7 +126,7 @@ class ActivitypubMixin:
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),
@ -118,7 +134,7 @@ class ActivitypubMixin:
)
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"
# is this activity owned by a user (statuses, lists, shelves), or is it
@ -132,13 +148,17 @@ class ActivitypubMixin:
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":
# 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
@ -159,32 +179,34 @@ class ActivitypubMixin:
"inbox", flat=True
)
recipients += list(shared_inboxes) + list(inboxes)
return recipients
return list(set(recipients))
def to_activity_dataclass(self):
""" convert from a model to an activity """
"""convert from a model to an activity"""
activity = generate_activity(self)
return self.activity_serializer(**activity)
def to_activity(self, **kwargs): # pylint: disable=unused-argument
""" convert from a model to a json activity """
"""convert from a model to a json activity"""
return self.to_activity_dataclass().serialize()
class ObjectMixin(ActivitypubMixin):
""" add this mixin for object models that are AP serializable """
"""add this mixin for object models that are AP serializable"""
def save(self, *args, created=None, **kwargs):
""" broadcast created/updated/deleted objects as appropriate """
"""broadcast created/updated/deleted objects as appropriate"""
broadcast = kwargs.get("broadcast", True)
# this bonus kwarg woul cause an error in the base save method
# 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)
@ -232,7 +254,7 @@ class ObjectMixin(ActivitypubMixin):
self.broadcast(activity, user)
def to_create_activity(self, user, **kwargs):
""" returns the object wrapped in a Create activity """
"""returns the object wrapped in a Create activity"""
activity_object = self.to_activity_dataclass(**kwargs)
signature = None
@ -258,7 +280,7 @@ class ObjectMixin(ActivitypubMixin):
).serialize()
def to_delete_activity(self, user):
""" notice of deletion """
"""notice of deletion"""
return activitypub.Delete(
id=self.remote_id + "/activity",
actor=user.remote_id,
@ -268,7 +290,7 @@ class ObjectMixin(ActivitypubMixin):
).serialize()
def to_update_activity(self, user):
""" wrapper for Updates to an activity """
"""wrapper for Updates to an activity"""
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
return activitypub.Update(
id=activity_id,
@ -284,13 +306,13 @@ class OrderedCollectionPageMixin(ObjectMixin):
@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 """
"""an ordered collection of whatevers"""
if not queryset.ordered:
raise RuntimeError("queryset must be ordered")
@ -319,11 +341,11 @@ class OrderedCollectionPageMixin(ObjectMixin):
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 """
"""usually an ordered collection model aggregates a different model"""
raise NotImplementedError("Model must define collection_queryset")
activity_serializer = activitypub.OrderedCollection
@ -332,81 +354,98 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
return self.to_ordered_collection(self.collection_queryset, **kwargs)
def to_activity(self, **kwargs):
""" an ordered collection of the specified model queryset """
"""an ordered collection of the specified model queryset"""
return self.to_ordered_collection(
self.collection_queryset, **kwargs
).serialize()
class CollectionItemMixin(ActivitypubMixin):
""" for items that are part of an (Ordered)Collection """
"""for items that are part of an (Ordered)Collection"""
activity_serializer = activitypub.Add
object_field = collection_field = None
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)
if self.user.local:
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=self.remote_id,
actor=self.user.remote_id,
object=object_field,
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=self.remote_id,
actor=self.user.remote_id,
object=object_field,
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
if broadcast and user.local:
self.broadcast(self.to_activity(), user)
def delete(self, *args, broadcast=True, **kwargs):
""" nevermind, undo that activity """
"""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 """
"""undo an action"""
user = self.user if hasattr(self, "user") else self.user_subject
return activitypub.Undo(
id="%s#undo" % self.remote_id,
@ -416,7 +455,7 @@ class ActivityMixin(ActivitypubMixin):
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)
@ -430,7 +469,7 @@ def generate_activity(obj):
) in obj.serialize_reverse_fields:
related_field = getattr(obj, model_field_name)
activity[activity_field_name] = unfurl_related_field(
related_field, sort_field
related_field, sort_field=sort_field
)
if not activity.get("id"):
@ -439,8 +478,8 @@ def generate_activity(obj):
def unfurl_related_field(related_field, sort_field=None):
""" load reverse lookups (like public key owner or Status attachment """
if hasattr(related_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()
]
@ -455,7 +494,7 @@ def unfurl_related_field(related_field, sort_field=None):
@app.task
def broadcast_task(sender_id, activity, recipients):
""" the celery task for broadcast """
"""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:
@ -466,7 +505,7 @@ def broadcast_task(sender_id, activity, recipients):
def sign_and_send(sender, data, destination):
""" crpyto whatever and http junk """
"""crpyto whatever and http junk"""
now = http_date()
if not sender.key_pair.private_key:
@ -495,10 +534,10 @@ def sign_and_send(sender, data, destination):
def to_ordered_collection_page(
queryset, remote_id, id_only=False, page=1, pure=False, **kwargs
):
""" serialize and pagiante a queryset """
"""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:

View File

@ -8,7 +8,7 @@ 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
@ -16,13 +16,13 @@ class Attachment(ActivitypubMixin, BookWyrmModel):
reverse_unfurl = True
class Meta:
""" one day we'll have other types of attachments besides images """
"""one day we'll have other types of attachments besides images"""
abstract = True
class Image(Attachment):
""" an image attachment """
"""an image attachment"""
image = fields.ImageField(
upload_to="status/",
@ -33,4 +33,4 @@ class Image(Attachment):
)
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
activity_serializer = activitypub.Image
activity_serializer = activitypub.Document

View File

@ -9,7 +9,7 @@ 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
@ -33,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 """
"""editions and works both use "book" instead of model_name"""
return "https://%s/author/%s" % (DOMAIN, self.id)
activity_serializer = activitypub.Author

View File

@ -7,14 +7,14 @@ from .fields import RemoteIdField
class BookWyrmModel(models.Model):
""" shared fields """
"""shared fields"""
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
remote_id = RemoteIdField(null=True, activitypub_field="id")
def get_remote_id(self):
""" generate a url that resolves to the local object """
"""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)
@ -22,20 +22,50 @@ class BookWyrmModel(models.Model):
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 """
"""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 set_remote_id(sender, instance, created, *args, **kwargs):
""" set the remote_id after save (when the id is available) """
"""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:

View File

@ -13,7 +13,7 @@ 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(
@ -32,15 +32,19 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
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:
@ -49,24 +53,24 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
return super().save(*args, **kwargs)
def broadcast(self, activity, sender, software="bookwyrm"):
""" only send book data updates to other bookwyrm instances """
"""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 """
"""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
@ -85,17 +89,17 @@ class Book(BookDataModel):
@property
def author_text(self):
""" format a list of authors """
"""format a list of authors"""
return ", ".join(a.name for a in self.authors.all())
@property
def latest_readthrough(self):
""" most recent readthrough activity """
"""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"
@ -108,20 +112,20 @@ class Book(BookDataModel):
@property
def alt_text(self):
""" image alt test """
"""image alt test"""
text = "%s" % self.title
if 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")
return super().save(*args, **kwargs)
def get_remote_id(self):
""" editions and works both use "book" instead of model_name """
"""editions and works both use "book" instead of model_name"""
return "https://%s/book/%d" % (DOMAIN, self.id)
def __repr__(self):
@ -133,7 +137,7 @@ 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(
@ -145,19 +149,19 @@ class Work(OrderedCollectionPageMixin, Book):
)
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 """
"""in case the default edition is not set"""
return self.default_edition or self.editions.order_by("-edition_rank").first()
@transaction.atomic()
def reset_default_edition(self):
""" sets a new default edition based on computed rank """
"""sets a new default edition based on computed rank"""
self.default_edition = None
# editions are re-ranked implicitly
self.save()
@ -165,11 +169,11 @@ class Work(OrderedCollectionPageMixin, Book):
self.save()
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
**kwargs,
)
activity_serializer = activitypub.Work
@ -178,7 +182,7 @@ class Work(OrderedCollectionPageMixin, Book):
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(
@ -217,7 +221,7 @@ class Edition(Book):
name_field = "title"
def get_rank(self, ignore_default=False):
""" calculate how complete the data is on this edition """
"""calculate how complete the data is on this edition"""
if (
not ignore_default
and self.parent_work
@ -237,7 +241,7 @@ 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:
self.isbn_10 = isbn_13_to_10(self.isbn_13)
@ -251,7 +255,7 @@ class Edition(Book):
def isbn_10_to_13(isbn_10):
""" convert an isbn 10 into an isbn 13 """
"""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]
@ -273,7 +277,7 @@ def isbn_10_to_13(isbn_10):
def isbn_13_to_10(isbn_13):
""" convert isbn 13 to 10, if possible """
"""convert isbn 13 to 10, if possible"""
if isbn_13[:3] != "978":
return None

View File

@ -9,7 +9,7 @@ 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)

View File

@ -11,7 +11,7 @@ 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"
@ -24,11 +24,11 @@ class Favorite(ActivityMixin, BookWyrmModel):
@classmethod
def ignore_activity(cls, activity):
""" don't bother with incoming favs of unknown statuses """
"""don't bother with incoming favs of unknown statuses"""
return not Status.objects.filter(remote_id=activity.object).exists()
def save(self, *args, **kwargs):
""" update user active time """
"""update user active time"""
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False)
super().save(*args, **kwargs)
@ -45,7 +45,7 @@ class Favorite(ActivityMixin, BookWyrmModel):
)
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(
@ -62,6 +62,6 @@ class Favorite(ActivityMixin, BookWyrmModel):
super().delete(*args, **kwargs)
class Meta:
""" can't fav things twice """
"""can't fav things twice"""
unique_together = ("user", "status")

View File

@ -1,17 +1,51 @@
""" connections to external ActivityPub servers """
from urllib.parse import urlparse
from django.db import models
from .base_model import BookWyrmModel
FederationStatus = models.TextChoices(
"Status",
[
"federated",
"blocked",
],
)
class FederatedServer(BookWyrmModel):
""" store which servers we federate with """
"""store which servers we federate with"""
server_name = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else
status = models.CharField(max_length=255, default="federated")
status = models.CharField(
max_length=255, default="federated", choices=FederationStatus.choices
)
# is it mastodon, bookwyrm, etc
application_type = models.CharField(max_length=255, null=True)
application_version = models.CharField(max_length=255, null=True)
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()
# TODO: blocked servers
# deactivate all associated users
self.user_set.filter(is_active=True).update(
is_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
)
@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

@ -18,7 +18,7 @@ from bookwyrm.settings import DOMAIN
def validate_remote_id(value):
""" make sure the remote_id looks like a url """
"""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"),
@ -27,7 +27,7 @@ def validate_remote_id(value):
def validate_localname(value):
""" make sure localnames look okay """
"""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"),
@ -36,7 +36,7 @@ def validate_localname(value):
def validate_username(value):
""" make sure usernames look okay """
"""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"),
@ -45,7 +45,7 @@ def validate_username(value):
class ActivitypubFieldMixin:
""" make a database field serializable """
"""make a database field serializable"""
def __init__(
self,
@ -64,7 +64,7 @@ class ActivitypubFieldMixin:
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:
@ -78,7 +78,7 @@ class ActivitypubFieldMixin:
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:
@ -94,19 +94,19 @@ class ActivitypubFieldMixin:
activity[key] = formatted
def field_to_activity(self, value):
""" formatter to convert a model value into activitypub """
"""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 """
"""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]
@ -115,7 +115,7 @@ class ActivitypubFieldMixin:
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
@ -146,7 +146,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
""" a url that serves as a unique identifier """
"""a url that serves as a unique identifier"""
def __init__(self, *args, max_length=255, validators=None, **kwargs):
validators = validators or [validate_remote_id]
@ -156,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField):
class UsernameField(ActivitypubFieldMixin, models.CharField):
""" activitypub-aware username field """
"""activitypub-aware username field"""
def __init__(self, activitypub_field="preferredUsername", **kwargs):
self.activitypub_field = activitypub_field
@ -172,7 +172,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
)
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"]
@ -191,7 +191,7 @@ PrivacyLevels = models.TextChoices(
class PrivacyField(ActivitypubFieldMixin, models.CharField):
""" this maps to two differente activitypub fields """
"""this maps to two differente activitypub fields"""
public = "https://www.w3.org/ns/activitystreams#Public"
@ -236,7 +236,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
""" activitypub-aware foreign key field """
"""activitypub-aware foreign key field"""
def field_to_activity(self, value):
if not value:
@ -245,7 +245,7 @@ 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:
@ -254,14 +254,14 @@ class OneToOneField(ActivitypubRelatedFieldMixin, models.OneToOneField):
class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
""" activitypub-aware many to many field """
"""activitypub-aware many to many field"""
def __init__(self, *args, link_only=False, **kwargs):
self.link_only = link_only
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data):
""" helper function for assinging a value to the field """
"""helper function for assinging a value to the field"""
value = getattr(data, self.get_activitypub_field())
formatted = self.field_from_activity(value)
if formatted is None or formatted is MISSING:
@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
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)
@ -290,7 +293,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
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)
@ -330,17 +333,17 @@ class TagField(ManyToManyField):
def image_serializer(value, alt):
""" helper for serializing images """
"""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)
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
@ -348,7 +351,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
# 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:
@ -394,7 +397,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
""" activitypub-aware datetime field """
"""activitypub-aware datetime field"""
def field_to_activity(self, value):
if not value:
@ -413,7 +416,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
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:
@ -424,30 +427,30 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
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 """
"""activitypub-aware boolean field"""
def field_to_activity(self, value):
if not value:

View File

@ -20,7 +20,7 @@ GOODREADS_SHELVES = {
def unquote_string(text):
""" resolve csv quote weirdness """
"""resolve csv quote weirdness"""
match = re.match(r'="([^"]*)"', text)
if match:
return match.group(1)
@ -28,7 +28,7 @@ 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)
# Open library doesn't like including author initials in search term.
@ -38,7 +38,7 @@ def construct_search_term(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)
@ -51,7 +51,7 @@ class ImportJob(models.Model):
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(
@ -65,7 +65,7 @@ class ImportJob(models.Model):
class ImportItem(models.Model):
""" a single line of a csv being imported """
"""a single line of a csv being imported"""
job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
index = models.IntegerField()
@ -74,11 +74,11 @@ class ImportItem(models.Model):
fail_reason = models.TextField(null=True)
def resolve(self):
""" try various ways to lookup a book """
"""try various ways to lookup a book"""
self.book = self.get_book_from_isbn() or self.get_book_from_title_author()
def get_book_from_isbn(self):
""" search by isbn """
"""search by isbn"""
search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999
)
@ -88,7 +88,7 @@ class ImportItem(models.Model):
return None
def get_book_from_title_author(self):
""" search by title and 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
@ -100,60 +100,60 @@ class ImportItem(models.Model):
@property
def title(self):
""" get the book title """
"""get the book title"""
return self.data["Title"]
@property
def author(self):
""" get the book title """
"""get the book title"""
return self.data["Author"]
@property
def isbn(self):
""" pulls out the isbn13 field from the csv line data """
"""pulls out the isbn13 field from the csv line data"""
return unquote_string(self.data["ISBN13"])
@property
def shelf(self):
""" the goodreads shelf field """
"""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 """
"""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 """
"""x/5 star rating for a book"""
return int(self.data["My Rating"])
@property
def date_added(self):
""" when the book was added to this dataset """
"""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 """
"""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 """
"""the date a book was completed"""
if self.data["Date Read"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
return None
@property
def reads(self):
""" formats a read through dataset for the book in this line """
"""formats a read through dataset for the book in this line"""
start_date = self.date_started
# Goodreads special case (no 'date started' field)

View File

@ -21,7 +21,7 @@ CurationType = models.TextChoices(
class List(OrderedCollectionMixin, BookWyrmModel):
""" a list of books """
"""a list of books"""
name = fields.CharField(max_length=100)
user = fields.ForeignKey(
@ -41,43 +41,40 @@ class List(OrderedCollectionMixin, BookWyrmModel):
activity_serializer = activitypub.BookList
def get_remote_id(self):
""" don't want the user to be in there in this case """
"""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 """
"""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"
)
notes = fields.TextField(blank=True, null=True)
approved = models.BooleanField(default=True)
order = fields.IntegerField(blank=True, null=True)
order = fields.IntegerField()
endorsement = models.ManyToManyField("User", related_name="endorsers")
activity_serializer = activitypub.Add
object_field = "book"
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
@ -96,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
)
class Meta:
""" an opinionated constraint! you can't put a book on a list twice """
unique_together = ("book", "book_list")
# 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

@ -10,7 +10,7 @@ NotificationType = models.TextChoices(
class Notification(BookWyrmModel):
""" you've been tagged, liked, followed, etc """
"""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)
@ -29,7 +29,7 @@ class Notification(BookWyrmModel):
)
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,
@ -45,7 +45,7 @@ class Notification(BookWyrmModel):
super().save(*args, **kwargs)
class Meta:
""" checks if notifcation is in enum list for valid types """
"""checks if notifcation is in enum list for valid types"""
constraints = [
models.CheckConstraint(

View File

@ -7,14 +7,14 @@ from .base_model import BookWyrmModel
class ProgressMode(models.TextChoices):
""" types of prgress available """
"""types of prgress available"""
PAGE = "PG", "page"
PERCENT = "PCT", "percent"
class ReadThrough(BookWyrmModel):
""" Store a read through a book in the database. """
"""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)
@ -28,13 +28,13 @@ class ReadThrough(BookWyrmModel):
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 """
"""add update to the readthrough"""
if self.progress:
return self.progressupdate_set.create(
user=self.user, progress=self.progress, mode=self.progress_mode
@ -43,7 +43,7 @@ class ReadThrough(BookWyrmModel):
class ProgressUpdate(BookWyrmModel):
""" Store progress through a book in the database. """
"""Store progress through a book in the database."""
user = models.ForeignKey("User", on_delete=models.PROTECT)
readthrough = models.ForeignKey("ReadThrough", on_delete=models.CASCADE)
@ -53,7 +53,7 @@ class ProgressUpdate(BookWyrmModel):
)
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

@ -11,7 +11,7 @@ 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",
@ -28,16 +28,16 @@ class UserRelationship(BookWyrmModel):
@property
def privacy(self):
""" all relationships are handled directly with the participants """
"""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 = [
@ -50,24 +50,23 @@ class UserRelationship(BookWyrmModel):
),
]
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(ActivityMixin, UserRelationship):
""" Following a user """
"""Following a user"""
status = "follows"
def to_activity(self): # pylint: disable=arguments-differ
""" overrides default to manually set serializer """
"""overrides default to manually set serializer"""
return activitypub.Follow(**generate_activity(self))
def save(self, *args, **kwargs):
""" really really don't let a user follow someone who blocked them """
"""really really don't let a user follow someone who blocked them"""
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
@ -86,7 +85,7 @@ class UserFollows(ActivityMixin, UserRelationship):
@classmethod
def from_request(cls, follow_request):
""" converts a follow request into a follow relationship """
"""converts a follow request into a follow relationship"""
return cls.objects.create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
@ -95,19 +94,22 @@ class UserFollows(ActivityMixin, UserRelationship):
class UserFollowRequest(ActivitypubMixin, UserRelationship):
""" following a user requires manual or automatic confirmation """
"""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 """
# don't create a request if a follow already exists
"""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():
raise IntegrityError()
self.accept(broadcast_only=True)
return
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
@ -138,25 +140,34 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
notification_type=notification_type,
)
def accept(self):
""" turn this request into the real deal"""
def get_accept_reject_id(self, status):
"""get id for sending an accept or reject of a local user"""
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
if not self.user_subject.local:
activity = activitypub.Accept(
id=self.get_remote_id(status="accepts"),
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()
def reject(self):
""" generate a Reject for this follow request """
"""generate a Reject for this follow request"""
if self.user_object.local:
activity = activitypub.Reject(
id=self.get_remote_id(status="rejects"),
id=self.get_accept_reject_id(status="rejects"),
actor=self.user_object.remote_id,
object=self.to_activity(),
).serialize()
@ -166,13 +177,13 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
class UserBlocks(ActivityMixin, UserRelationship):
""" prevent another user from following you and seeing your posts """
"""prevent another user from following you and seeing your posts"""
status = "blocks"
activity_serializer = activitypub.Block
def save(self, *args, **kwargs):
""" remove follow or follow request rels after a block is created """
"""remove follow or follow request rels after a block is created"""
super().save(*args, **kwargs)
UserFollows.objects.filter(

View File

@ -6,7 +6,7 @@ from .base_model import BookWyrmModel
class Report(BookWyrmModel):
""" reported status or user """
"""reported status or user"""
reporter = models.ForeignKey(
"User", related_name="reporter", on_delete=models.PROTECT
@ -17,7 +17,7 @@ class Report(BookWyrmModel):
resolved = models.BooleanField(default=False)
def save(self, *args, **kwargs):
""" notify admins when a report is created """
"""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
@ -34,7 +34,7 @@ class Report(BookWyrmModel):
)
class Meta:
""" don't let users report themselves """
"""don't let users report themselves"""
constraints = [
models.CheckConstraint(check=~Q(reporter=F("user")), name="self_report")
@ -43,13 +43,13 @@ class Report(BookWyrmModel):
class ReportComment(BookWyrmModel):
""" updates on a report """
"""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 """
"""sort comments"""
ordering = ("-created_date",)

View File

@ -9,7 +9,7 @@ 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"
@ -34,49 +34,46 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
activity_serializer = activitypub.Shelf
def save(self, *args, **kwargs):
""" set the identifier """
"""set the identifier"""
super().save(*args, **kwargs)
if not self.identifier:
self.identifier = self.get_identifier()
super().save(*args, **kwargs, broadcast=False)
def get_identifier(self):
""" custom-shelf-123 for the url """
"""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
identifier = self.identifier or self.get_identifier()
return "%s/books/%s" % (base_path, identifier)
class Meta:
""" user/shelf unqiueness """
"""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"
)
activity_serializer = activitypub.Add
object_field = "book"
activity_serializer = activitypub.ShelfItem
collection_field = "shelf"
def save(self, *args, **kwargs):

View File

@ -12,7 +12,7 @@ from .user import User
class SiteSettings(models.Model):
""" customized settings for this instance """
"""customized settings for this instance"""
name = models.CharField(default="BookWyrm", max_length=100)
instance_tagline = models.CharField(
@ -35,7 +35,7 @@ class SiteSettings(models.Model):
@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:
@ -45,12 +45,12 @@ class SiteSettings(models.Model):
def new_access_code():
""" the identifier for a user invite """
"""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)
@ -61,19 +61,19 @@ class SiteInvite(models.Model):
invitees = models.ManyToManyField(User, related_name="invitees")
def valid(self):
""" make sure it hasn't expired or been used """
"""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 """
"""formats the invite link"""
return "https://{}/invite/{}".format(DOMAIN, self.code)
class InviteRequest(BookWyrmModel):
""" prospective users can request an invite """
"""prospective users can request an invite"""
email = models.EmailField(max_length=255, unique=True)
invite = models.ForeignKey(
@ -83,30 +83,30 @@ class InviteRequest(BookWyrmModel):
ignored = models.BooleanField(default=False)
def save(self, *args, **kwargs):
""" don't create a request for a registered email """
"""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 """
"""formats the invite link"""
return "https://{}/password-reset/{}".format(DOMAIN, self.code)

View File

@ -19,7 +19,7 @@ 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"
@ -59,12 +59,12 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
deserialize_reverse_fields = [("attachments", "attachment")]
class Meta:
""" default sorting """
"""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)
@ -98,7 +98,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
)
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status """
""" "delete" a status"""
if hasattr(self, "boosted_status"):
# okay but if it's a boost really delete it
super().delete(*args, **kwargs)
@ -109,7 +109,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@property
def recipients(self):
""" tagged users who definitely need to get this status in broadcast """
"""tagged users who definitely need to get this status in broadcast"""
mentions = [u for u in self.mention_users.all() if not u.local]
if (
hasattr(self, "reply_parent")
@ -121,7 +121,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@classmethod
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
""" keep notes if they are replies to existing statuses """
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
try:
boosted = activitypub.resolve_remote_id(
@ -163,16 +163,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@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 """
"""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,
@ -181,7 +181,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
).serialize()
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
""" return tombstone if the status is deleted """
"""return tombstone if the status is deleted"""
if self.deleted:
return activitypub.Tombstone(
id=self.remote_id,
@ -210,16 +210,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
return activity
def to_activity(self, pure=False): # pylint: disable=arguments-differ
""" json serialized activitypub class """
"""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)
@ -232,7 +232,7 @@ class GeneratedNote(Status):
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"
@ -253,7 +253,7 @@ class Comment(Status):
@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 '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
self.content,
self.book.remote_id,
@ -265,7 +265,7 @@ class Comment(Status):
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(
@ -274,7 +274,7 @@ class Quotation(Status):
@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"""
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' % (
@ -289,7 +289,7 @@ class Quotation(Status):
class Review(Status):
""" a book review """
"""a book review"""
name = fields.CharField(max_length=255, null=True)
book = fields.ForeignKey(
@ -306,7 +306,7 @@ class Review(Status):
@property
def pure_name(self):
""" clarify review names for mastodon serialization """
"""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}
@ -314,7 +314,7 @@ class Review(Status):
@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
@ -322,7 +322,7 @@ class Review(Status):
class ReviewRating(Review):
""" a subtype of review that only contains a rating """
"""a subtype of review that only contains a rating"""
def save(self, *args, **kwargs):
if not self.rating:
@ -339,7 +339,7 @@ class ReviewRating(Review):
class Boost(ActivityMixin, Status):
""" boost'ing a post """
"""boost'ing a post"""
boosted_status = fields.ForeignKey(
"Status",
@ -350,7 +350,17 @@ class Boost(ActivityMixin, Status):
activity_serializer = activitypub.Announce
def save(self, *args, **kwargs):
""" save and notify """
"""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
super().save(*args, **kwargs)
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
return
@ -364,7 +374,7 @@ class Boost(ActivityMixin, Status):
)
def delete(self, *args, **kwargs):
""" delete and un-notify """
"""delete and un-notify"""
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_model.objects.filter(
user=self.boosted_status.user,
@ -375,7 +385,7 @@ class Boost(ActivityMixin, Status):
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", "published_date", "privacy"]

View File

@ -1,63 +0,0 @@
""" models for storing different kinds of Activities """
import urllib.parse
from django.apps import apps
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)
@property
def books(self):
""" count of books associated with this tag """
edition_model = apps.get_model("bookwyrm.Edition", require_ready=True)
return (
edition_model.objects.filter(usertag__tag__identifier=self.identifier)
.order_by("-created_date")
.distinct()
)
collection_queryset = books
def get_remote_id(self):
""" tag should use identifier not id in remote_id """
base_path = "https://%s" % DOMAIN
return "%s/tag/%s" % (base_path, self.identifier)
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.Add
object_field = "book"
collection_field = "tag"
class Meta:
""" unqiueness constraint """
unique_together = ("user", "book", "tag")

View File

@ -4,6 +4,7 @@ from urllib.parse import urlparse
from django.apps import apps
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.utils import timezone
@ -23,8 +24,18 @@ from .federated_server import FederatedServer
from . import fields, Review
DeactivationReason = models.TextChoices(
"DeactivationReason",
[
"self_deletion",
"moderator_deletion",
"domain_block",
],
)
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)
@ -54,7 +65,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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,
@ -110,33 +121,47 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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)
@property
def alt_text(self):
""" alt text with 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 """
"""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
activity_serializer = activitypub.Person
@classmethod
def viewer_aware_objects(cls, viewer):
""" the user queryset filtered for the context of the logged in user """
"""the user queryset filtered for the context of the logged in user"""
queryset = cls.objects.filter(is_active=True)
if viewer.is_authenticated:
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
@ -163,7 +188,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
).serialize()
def to_following_activity(self, **kwargs):
""" activitypub following list """
"""activitypub following list"""
remote_id = "%s/following" % self.remote_id
return self.to_ordered_collection(
self.following.order_by("-updated_date").all(),
@ -173,7 +198,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
)
def to_followers_activity(self, **kwargs):
""" activitypub followers list """
"""activitypub followers list"""
remote_id = "%s/followers" % self.remote_id
return self.to_ordered_collection(
self.followers.order_by("-updated_date").all(),
@ -185,6 +210,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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",
@ -199,7 +227,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
return activity_object
def save(self, *args, **kwargs):
""" populate fields for new local users """
"""populate fields for new local users"""
created = not bool(self.id)
if not self.local and not re.match(regex.full_username, self.username):
# generate a username that uses the domain (webfinger format)
@ -263,14 +291,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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 """
"""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(
@ -285,7 +319,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
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"]
@ -303,7 +337,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
class AnnualGoal(BookWyrmModel):
""" set a goal for how many books you read in a year """
"""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)])
@ -313,17 +347,17 @@ class AnnualGoal(BookWyrmModel):
)
class Meta:
""" unqiueness constraint """
"""unqiueness constraint"""
unique_together = ("user", "year")
def get_remote_id(self):
""" put the year in the path """
"""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 """
"""the books you've read this year"""
return (
self.user.readthrough_set.filter(finish_date__year__gte=self.year)
.order_by("-finish_date")
@ -332,7 +366,7 @@ class AnnualGoal(BookWyrmModel):
@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,
@ -342,12 +376,12 @@ class AnnualGoal(BookWyrmModel):
@property
def progress_percent(self):
""" how close to your goal, in percent form """
"""how close to your goal, in percent form"""
return int(float(self.book_count / self.goal) * 100)
@property
def book_count(self):
""" how many books you've read this year """
"""how many books you've read this year"""
return self.user.readthrough_set.filter(
finish_date__year__gte=self.year
).count()
@ -355,7 +389,7 @@ class AnnualGoal(BookWyrmModel):
@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)
@ -365,7 +399,7 @@ def set_remote_server(user_id):
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)
except FederatedServer.DoesNotExist:
@ -394,7 +428,7 @@ def get_or_create_remote_server(domain):
@app.task
def get_remote_reviews(outbox):
""" ingest reviews by a new remote bookwyrm user """
"""ingest reviews by a new remote bookwyrm user"""
outbox_page = outbox + "?page=true&type=Review"
data = get_data(outbox_page)