mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-26 19:41:11 +00:00
Merge branch 'main' into portable-hashbangs
This commit is contained in:
commit
3c57797852
14 changed files with 296 additions and 64 deletions
|
@ -100,9 +100,27 @@ class ActivityObject:
|
||||||
|
|
||||||
# pylint: disable=too-many-locals,too-many-branches,too-many-arguments
|
# pylint: disable=too-many-locals,too-many-branches,too-many-arguments
|
||||||
def to_model(
|
def to_model(
|
||||||
self, model=None, instance=None, allow_create=True, save=True, overwrite=True
|
self,
|
||||||
|
model=None,
|
||||||
|
instance=None,
|
||||||
|
allow_create=True,
|
||||||
|
save=True,
|
||||||
|
overwrite=True,
|
||||||
|
allow_external_connections=True,
|
||||||
):
|
):
|
||||||
"""convert from an activity to a model instance"""
|
"""convert from an activity to a model instance. Args:
|
||||||
|
model: the django model that this object is being converted to
|
||||||
|
(will guess if not known)
|
||||||
|
instance: an existing database entry that is going to be updated by
|
||||||
|
this activity
|
||||||
|
allow_create: whether a new object should be created if there is no
|
||||||
|
existing object is provided or found matching the remote_id
|
||||||
|
save: store in the database if true, return an unsaved model obj if false
|
||||||
|
overwrite: replace fields in the database with this activity if true,
|
||||||
|
only update blank fields if false
|
||||||
|
allow_external_connections: look up missing data if true,
|
||||||
|
throw an exception if false and an external connection is needed
|
||||||
|
"""
|
||||||
model = model or get_model_from_type(self.type)
|
model = model or get_model_from_type(self.type)
|
||||||
|
|
||||||
# only reject statuses if we're potentially creating them
|
# only reject statuses if we're potentially creating them
|
||||||
|
@ -127,7 +145,10 @@ class ActivityObject:
|
||||||
for field in instance.simple_fields:
|
for field in instance.simple_fields:
|
||||||
try:
|
try:
|
||||||
changed = field.set_field_from_activity(
|
changed = field.set_field_from_activity(
|
||||||
instance, self, overwrite=overwrite
|
instance,
|
||||||
|
self,
|
||||||
|
overwrite=overwrite,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
)
|
)
|
||||||
if changed:
|
if changed:
|
||||||
update_fields.append(field.name)
|
update_fields.append(field.name)
|
||||||
|
@ -138,7 +159,11 @@ class ActivityObject:
|
||||||
# too early and jank up users
|
# too early and jank up users
|
||||||
for field in instance.image_fields:
|
for field in instance.image_fields:
|
||||||
changed = field.set_field_from_activity(
|
changed = field.set_field_from_activity(
|
||||||
instance, self, save=save, overwrite=overwrite
|
instance,
|
||||||
|
self,
|
||||||
|
save=save,
|
||||||
|
overwrite=overwrite,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
)
|
)
|
||||||
if changed:
|
if changed:
|
||||||
update_fields.append(field.name)
|
update_fields.append(field.name)
|
||||||
|
@ -162,7 +187,11 @@ class ActivityObject:
|
||||||
# add many to many fields, which have to be set post-save
|
# add many to many fields, which have to be set post-save
|
||||||
for field in instance.many_to_many_fields:
|
for field in instance.many_to_many_fields:
|
||||||
# mention books/users, for example
|
# mention books/users, for example
|
||||||
field.set_field_from_activity(instance, self)
|
field.set_field_from_activity(
|
||||||
|
instance,
|
||||||
|
self,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
|
|
||||||
# reversed relationships in the models
|
# reversed relationships in the models
|
||||||
for (
|
for (
|
||||||
|
@ -266,10 +295,22 @@ def get_model_from_type(activity_type):
|
||||||
return model[0]
|
return model[0]
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-arguments
|
||||||
def resolve_remote_id(
|
def resolve_remote_id(
|
||||||
remote_id, model=None, refresh=False, save=True, get_activity=False
|
remote_id,
|
||||||
|
model=None,
|
||||||
|
refresh=False,
|
||||||
|
save=True,
|
||||||
|
get_activity=False,
|
||||||
|
allow_external_connections=True,
|
||||||
):
|
):
|
||||||
"""take a remote_id and return an instance, creating if necessary"""
|
"""take a remote_id and return an instance, creating if necessary. Args:
|
||||||
|
remote_id: the unique url for looking up the object in the db or by http
|
||||||
|
model: a string or object representing the model that corresponds to the object
|
||||||
|
save: whether to return an unsaved database entry or a saved one
|
||||||
|
get_activity: whether to return the activitypub object or the model object
|
||||||
|
allow_external_connections: whether to make http connections
|
||||||
|
"""
|
||||||
if model: # a bonus check we can do if we already know the model
|
if model: # a bonus check we can do if we already know the model
|
||||||
if isinstance(model, str):
|
if isinstance(model, str):
|
||||||
model = apps.get_model(f"bookwyrm.{model}", require_ready=True)
|
model = apps.get_model(f"bookwyrm.{model}", require_ready=True)
|
||||||
|
@ -277,6 +318,13 @@ def resolve_remote_id(
|
||||||
if result and not refresh:
|
if result and not refresh:
|
||||||
return result if not get_activity else result.to_activity_dataclass()
|
return result if not get_activity else result.to_activity_dataclass()
|
||||||
|
|
||||||
|
# The above block will return the object if it already exists in the database.
|
||||||
|
# If it doesn't, an external connection would be needed, so check if that's cool
|
||||||
|
if not allow_external_connections:
|
||||||
|
raise ActivitySerializerError(
|
||||||
|
"Unable to serialize object without making external HTTP requests"
|
||||||
|
)
|
||||||
|
|
||||||
# load the data and create the object
|
# load the data and create the object
|
||||||
try:
|
try:
|
||||||
data = get_data(remote_id)
|
data = get_data(remote_id)
|
||||||
|
|
|
@ -14,12 +14,12 @@ class Verb(ActivityObject):
|
||||||
actor: str
|
actor: str
|
||||||
object: ActivityObject
|
object: ActivityObject
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""usually we just want to update and save"""
|
"""usually we just want to update and save"""
|
||||||
# self.object may return None if the object is invalid in an expected way
|
# self.object may return None if the object is invalid in an expected way
|
||||||
# ie, Question type
|
# ie, Question type
|
||||||
if self.object:
|
if self.object:
|
||||||
self.object.to_model()
|
self.object.to_model(allow_external_connections=allow_external_connections)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
@ -42,7 +42,7 @@ class Delete(Verb):
|
||||||
cc: List[str] = field(default_factory=lambda: [])
|
cc: List[str] = field(default_factory=lambda: [])
|
||||||
type: str = "Delete"
|
type: str = "Delete"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""find and delete the activity object"""
|
"""find and delete the activity object"""
|
||||||
if not self.object:
|
if not self.object:
|
||||||
return
|
return
|
||||||
|
@ -52,7 +52,11 @@ class Delete(Verb):
|
||||||
model = apps.get_model("bookwyrm.User")
|
model = apps.get_model("bookwyrm.User")
|
||||||
obj = model.find_existing_by_remote_id(self.object)
|
obj = model.find_existing_by_remote_id(self.object)
|
||||||
else:
|
else:
|
||||||
obj = self.object.to_model(save=False, allow_create=False)
|
obj = self.object.to_model(
|
||||||
|
save=False,
|
||||||
|
allow_create=False,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
|
|
||||||
if obj:
|
if obj:
|
||||||
obj.delete()
|
obj.delete()
|
||||||
|
@ -67,11 +71,13 @@ class Update(Verb):
|
||||||
to: List[str]
|
to: List[str]
|
||||||
type: str = "Update"
|
type: str = "Update"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""update a model instance from the dataclass"""
|
"""update a model instance from the dataclass"""
|
||||||
if not self.object:
|
if not self.object:
|
||||||
return
|
return
|
||||||
self.object.to_model(allow_create=False)
|
self.object.to_model(
|
||||||
|
allow_create=False, allow_external_connections=allow_external_connections
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
|
@ -80,7 +86,7 @@ class Undo(Verb):
|
||||||
|
|
||||||
type: str = "Undo"
|
type: str = "Undo"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""find and remove the activity object"""
|
"""find and remove the activity object"""
|
||||||
if isinstance(self.object, str):
|
if isinstance(self.object, str):
|
||||||
# it may be that something should be done with these, but idk what
|
# it may be that something should be done with these, but idk what
|
||||||
|
@ -92,13 +98,28 @@ class Undo(Verb):
|
||||||
model = None
|
model = None
|
||||||
if self.object.type == "Follow":
|
if self.object.type == "Follow":
|
||||||
model = apps.get_model("bookwyrm.UserFollows")
|
model = apps.get_model("bookwyrm.UserFollows")
|
||||||
obj = self.object.to_model(model=model, save=False, allow_create=False)
|
obj = self.object.to_model(
|
||||||
|
model=model,
|
||||||
|
save=False,
|
||||||
|
allow_create=False,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
if not obj:
|
if not obj:
|
||||||
# this could be a follow request not a follow proper
|
# this could be a follow request not a follow proper
|
||||||
model = apps.get_model("bookwyrm.UserFollowRequest")
|
model = apps.get_model("bookwyrm.UserFollowRequest")
|
||||||
obj = self.object.to_model(model=model, save=False, allow_create=False)
|
obj = self.object.to_model(
|
||||||
|
model=model,
|
||||||
|
save=False,
|
||||||
|
allow_create=False,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
obj = self.object.to_model(model=model, save=False, allow_create=False)
|
obj = self.object.to_model(
|
||||||
|
model=model,
|
||||||
|
save=False,
|
||||||
|
allow_create=False,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
if not obj:
|
if not obj:
|
||||||
# if we don't have the object, we can't undo it. happens a lot with boosts
|
# if we don't have the object, we can't undo it. happens a lot with boosts
|
||||||
return
|
return
|
||||||
|
@ -112,9 +133,9 @@ class Follow(Verb):
|
||||||
object: str
|
object: str
|
||||||
type: str = "Follow"
|
type: str = "Follow"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""relationship save"""
|
"""relationship save"""
|
||||||
self.to_model()
|
self.to_model(allow_external_connections=allow_external_connections)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
|
@ -124,9 +145,9 @@ class Block(Verb):
|
||||||
object: str
|
object: str
|
||||||
type: str = "Block"
|
type: str = "Block"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""relationship save"""
|
"""relationship save"""
|
||||||
self.to_model()
|
self.to_model(allow_external_connections=allow_external_connections)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(init=False)
|
@dataclass(init=False)
|
||||||
|
@ -136,7 +157,7 @@ class Accept(Verb):
|
||||||
object: Follow
|
object: Follow
|
||||||
type: str = "Accept"
|
type: str = "Accept"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""accept a request"""
|
"""accept a request"""
|
||||||
obj = self.object.to_model(save=False, allow_create=True)
|
obj = self.object.to_model(save=False, allow_create=True)
|
||||||
obj.accept()
|
obj.accept()
|
||||||
|
@ -149,7 +170,7 @@ class Reject(Verb):
|
||||||
object: Follow
|
object: Follow
|
||||||
type: str = "Reject"
|
type: str = "Reject"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""reject a follow request"""
|
"""reject a follow request"""
|
||||||
obj = self.object.to_model(save=False, allow_create=False)
|
obj = self.object.to_model(save=False, allow_create=False)
|
||||||
obj.reject()
|
obj.reject()
|
||||||
|
@ -163,7 +184,7 @@ class Add(Verb):
|
||||||
object: CollectionItem
|
object: CollectionItem
|
||||||
type: str = "Add"
|
type: str = "Add"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""figure out the target to assign the item to a collection"""
|
"""figure out the target to assign the item to a collection"""
|
||||||
target = resolve_remote_id(self.target)
|
target = resolve_remote_id(self.target)
|
||||||
item = self.object.to_model(save=False)
|
item = self.object.to_model(save=False)
|
||||||
|
@ -177,7 +198,7 @@ class Remove(Add):
|
||||||
|
|
||||||
type: str = "Remove"
|
type: str = "Remove"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""find and remove the activity object"""
|
"""find and remove the activity object"""
|
||||||
obj = self.object.to_model(save=False, allow_create=False)
|
obj = self.object.to_model(save=False, allow_create=False)
|
||||||
if obj:
|
if obj:
|
||||||
|
@ -191,9 +212,9 @@ class Like(Verb):
|
||||||
object: str
|
object: str
|
||||||
type: str = "Like"
|
type: str = "Like"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""like"""
|
"""like"""
|
||||||
self.to_model()
|
self.to_model(allow_external_connections=allow_external_connections)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
@ -207,6 +228,6 @@ class Announce(Verb):
|
||||||
object: str
|
object: str
|
||||||
type: str = "Announce"
|
type: str = "Announce"
|
||||||
|
|
||||||
def action(self):
|
def action(self, allow_external_connections=True):
|
||||||
"""boost"""
|
"""boost"""
|
||||||
self.to_model()
|
self.to_model(allow_external_connections=allow_external_connections)
|
||||||
|
|
46
bookwyrm/migrations/0174_auto_20230222_1742.py
Normal file
46
bookwyrm/migrations/0174_auto_20230222_1742.py
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# Generated by Django 3.2.18 on 2023-02-22 17:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0174_auto_20230130_1240"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="notification",
|
||||||
|
name="related_link_domains",
|
||||||
|
field=models.ManyToManyField(to="bookwyrm.LinkDomain"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="notification",
|
||||||
|
name="notification_type",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("FAVORITE", "Favorite"),
|
||||||
|
("REPLY", "Reply"),
|
||||||
|
("MENTION", "Mention"),
|
||||||
|
("TAG", "Tag"),
|
||||||
|
("FOLLOW", "Follow"),
|
||||||
|
("FOLLOW_REQUEST", "Follow Request"),
|
||||||
|
("BOOST", "Boost"),
|
||||||
|
("IMPORT", "Import"),
|
||||||
|
("ADD", "Add"),
|
||||||
|
("REPORT", "Report"),
|
||||||
|
("LINK_DOMAIN", "Link Domain"),
|
||||||
|
("INVITE", "Invite"),
|
||||||
|
("ACCEPT", "Accept"),
|
||||||
|
("JOIN", "Join"),
|
||||||
|
("LEAVE", "Leave"),
|
||||||
|
("REMOVE", "Remove"),
|
||||||
|
("GROUP_PRIVACY", "Group Privacy"),
|
||||||
|
("GROUP_NAME", "Group Name"),
|
||||||
|
("GROUP_DESCRIPTION", "Group Description"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -67,7 +67,9 @@ class ActivitypubFieldMixin:
|
||||||
self.activitypub_field = activitypub_field
|
self.activitypub_field = activitypub_field
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
def set_field_from_activity(
|
||||||
|
self, instance, data, overwrite=True, allow_external_connections=True
|
||||||
|
):
|
||||||
"""helper function for assinging a value to the field. Returns if changed"""
|
"""helper function for assinging a value to the field. Returns if changed"""
|
||||||
try:
|
try:
|
||||||
value = getattr(data, self.get_activitypub_field())
|
value = getattr(data, self.get_activitypub_field())
|
||||||
|
@ -76,7 +78,9 @@ class ActivitypubFieldMixin:
|
||||||
if self.get_activitypub_field() != "attributedTo":
|
if self.get_activitypub_field() != "attributedTo":
|
||||||
raise
|
raise
|
||||||
value = getattr(data, "actor")
|
value = getattr(data, "actor")
|
||||||
formatted = self.field_from_activity(value)
|
formatted = self.field_from_activity(
|
||||||
|
value, allow_external_connections=allow_external_connections
|
||||||
|
)
|
||||||
if formatted is None or formatted is MISSING or formatted == {}:
|
if formatted is None or formatted is MISSING or formatted == {}:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -116,7 +120,8 @@ class ActivitypubFieldMixin:
|
||||||
return {self.activitypub_wrapper: value}
|
return {self.activitypub_wrapper: value}
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
# pylint: disable=unused-argument
|
||||||
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
"""formatter to convert activitypub into a model value"""
|
"""formatter to convert activitypub into a model value"""
|
||||||
if value and hasattr(self, "activitypub_wrapper"):
|
if value and hasattr(self, "activitypub_wrapper"):
|
||||||
value = value.get(self.activitypub_wrapper)
|
value = value.get(self.activitypub_wrapper)
|
||||||
|
@ -138,7 +143,7 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
||||||
self.load_remote = load_remote
|
self.load_remote = load_remote
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@ -159,7 +164,11 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
||||||
if not self.load_remote:
|
if not self.load_remote:
|
||||||
# only look in the local database
|
# only look in the local database
|
||||||
return related_model.find_existing_by_remote_id(value)
|
return related_model.find_existing_by_remote_id(value)
|
||||||
return activitypub.resolve_remote_id(value, model=related_model)
|
return activitypub.resolve_remote_id(
|
||||||
|
value,
|
||||||
|
model=related_model,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
||||||
|
@ -219,7 +228,9 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||||
super().__init__(*args, max_length=255, choices=PrivacyLevels, default="public")
|
super().__init__(*args, max_length=255, choices=PrivacyLevels, default="public")
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
def set_field_from_activity(
|
||||||
|
self, instance, data, overwrite=True, allow_external_connections=True
|
||||||
|
):
|
||||||
if not overwrite:
|
if not overwrite:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -234,7 +245,11 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||||
break
|
break
|
||||||
if not user_field:
|
if not user_field:
|
||||||
raise ValidationError("No user field found for privacy", data)
|
raise ValidationError("No user field found for privacy", data)
|
||||||
user = activitypub.resolve_remote_id(getattr(data, user_field), model="User")
|
user = activitypub.resolve_remote_id(
|
||||||
|
getattr(data, user_field),
|
||||||
|
model="User",
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
|
|
||||||
if to == [self.public]:
|
if to == [self.public]:
|
||||||
setattr(instance, self.name, "public")
|
setattr(instance, self.name, "public")
|
||||||
|
@ -295,13 +310,17 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
self.link_only = link_only
|
self.link_only = link_only
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
def set_field_from_activity(
|
||||||
|
self, instance, data, overwrite=True, allow_external_connections=True
|
||||||
|
):
|
||||||
"""helper function for assigning a value to the field"""
|
"""helper function for assigning a value to the field"""
|
||||||
if not overwrite and getattr(instance, self.name).exists():
|
if not overwrite and getattr(instance, self.name).exists():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
value = getattr(data, self.get_activitypub_field())
|
value = getattr(data, self.get_activitypub_field())
|
||||||
formatted = self.field_from_activity(value)
|
formatted = self.field_from_activity(
|
||||||
|
value, allow_external_connections=allow_external_connections
|
||||||
|
)
|
||||||
if formatted is None or formatted is MISSING:
|
if formatted is None or formatted is MISSING:
|
||||||
return False
|
return False
|
||||||
getattr(instance, self.name).set(formatted)
|
getattr(instance, self.name).set(formatted)
|
||||||
|
@ -313,7 +332,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
return f"{value.instance.remote_id}/{self.name}"
|
return f"{value.instance.remote_id}/{self.name}"
|
||||||
return [i.remote_id for i in value.all()]
|
return [i.remote_id for i in value.all()]
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
if value is None or value is MISSING:
|
if value is None or value is MISSING:
|
||||||
return None
|
return None
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
|
@ -326,7 +345,11 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
continue
|
continue
|
||||||
items.append(
|
items.append(
|
||||||
activitypub.resolve_remote_id(remote_id, model=self.related_model)
|
activitypub.resolve_remote_id(
|
||||||
|
remote_id,
|
||||||
|
model=self.related_model,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
@ -353,7 +376,7 @@ class TagField(ManyToManyField):
|
||||||
)
|
)
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return None
|
return None
|
||||||
items = []
|
items = []
|
||||||
|
@ -366,7 +389,11 @@ class TagField(ManyToManyField):
|
||||||
# tags can contain multiple types
|
# tags can contain multiple types
|
||||||
continue
|
continue
|
||||||
items.append(
|
items.append(
|
||||||
activitypub.resolve_remote_id(link.href, model=self.related_model)
|
activitypub.resolve_remote_id(
|
||||||
|
link.href,
|
||||||
|
model=self.related_model,
|
||||||
|
allow_external_connections=allow_external_connections,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
@ -390,11 +417,15 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
self.alt_field = alt_field
|
self.alt_field = alt_field
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
# pylint: disable=arguments-differ,arguments-renamed
|
# pylint: disable=arguments-differ,arguments-renamed,too-many-arguments
|
||||||
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
|
def set_field_from_activity(
|
||||||
|
self, instance, data, save=True, overwrite=True, allow_external_connections=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())
|
value = getattr(data, self.get_activitypub_field())
|
||||||
formatted = self.field_from_activity(value)
|
formatted = self.field_from_activity(
|
||||||
|
value, allow_external_connections=allow_external_connections
|
||||||
|
)
|
||||||
if formatted is None or formatted is MISSING:
|
if formatted is None or formatted is MISSING:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -426,7 +457,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||||
|
|
||||||
return activitypub.Document(url=url, name=alt)
|
return activitypub.Document(url=url, name=alt)
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
image_slug = value
|
image_slug = value
|
||||||
# when it's an inline image (User avatar/icon, Book cover), it's a json
|
# 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
|
# blob, but when it's an attached image, it's just a url
|
||||||
|
@ -481,7 +512,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||||
return None
|
return None
|
||||||
return value.isoformat()
|
return value.isoformat()
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
try:
|
try:
|
||||||
date_value = dateutil.parser.parse(value)
|
date_value = dateutil.parser.parse(value)
|
||||||
try:
|
try:
|
||||||
|
@ -495,7 +526,7 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||||
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||||
"""a text field for storing html"""
|
"""a text field for storing html"""
|
||||||
|
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value, allow_external_connections=True):
|
||||||
if not value or value == MISSING:
|
if not value or value == MISSING:
|
||||||
return None
|
return None
|
||||||
return clean(value)
|
return clean(value)
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report
|
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, LinkDomain
|
||||||
from . import Status, User, UserFollowRequest
|
from . import ListItem, Report, Status, User, UserFollowRequest
|
||||||
|
|
||||||
|
|
||||||
class Notification(BookWyrmModel):
|
class Notification(BookWyrmModel):
|
||||||
|
@ -28,6 +28,7 @@ class Notification(BookWyrmModel):
|
||||||
|
|
||||||
# Admin
|
# Admin
|
||||||
REPORT = "REPORT"
|
REPORT = "REPORT"
|
||||||
|
LINK_DOMAIN = "LINK_DOMAIN"
|
||||||
|
|
||||||
# Groups
|
# Groups
|
||||||
INVITE = "INVITE"
|
INVITE = "INVITE"
|
||||||
|
@ -43,7 +44,7 @@ class Notification(BookWyrmModel):
|
||||||
NotificationType = models.TextChoices(
|
NotificationType = models.TextChoices(
|
||||||
# there has got be a better way to do this
|
# there has got be a better way to do this
|
||||||
"NotificationType",
|
"NotificationType",
|
||||||
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
|
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {LINK_DOMAIN} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
|
||||||
)
|
)
|
||||||
|
|
||||||
user = models.ForeignKey("User", on_delete=models.CASCADE)
|
user = models.ForeignKey("User", on_delete=models.CASCADE)
|
||||||
|
@ -64,6 +65,7 @@ class Notification(BookWyrmModel):
|
||||||
"ListItem", symmetrical=False, related_name="notifications"
|
"ListItem", symmetrical=False, related_name="notifications"
|
||||||
)
|
)
|
||||||
related_reports = models.ManyToManyField("Report", symmetrical=False)
|
related_reports = models.ManyToManyField("Report", symmetrical=False)
|
||||||
|
related_link_domains = models.ManyToManyField("LinkDomain", symmetrical=False)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
|
@ -241,6 +243,26 @@ def notify_admins_on_report(sender, instance, created, *args, **kwargs):
|
||||||
notification.related_reports.add(instance)
|
notification.related_reports.add(instance)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(models.signals.post_save, sender=LinkDomain)
|
||||||
|
@transaction.atomic
|
||||||
|
# pylint: disable=unused-argument
|
||||||
|
def notify_admins_on_link_domain(sender, instance, created, *args, **kwargs):
|
||||||
|
"""a new link domain needs to be verified"""
|
||||||
|
if not created:
|
||||||
|
# otherwise you'll get a notification when you approve a domain
|
||||||
|
return
|
||||||
|
|
||||||
|
# moderators and superusers should be notified
|
||||||
|
admins = User.admins()
|
||||||
|
for admin in admins:
|
||||||
|
notification, _ = Notification.objects.get_or_create(
|
||||||
|
user=admin,
|
||||||
|
notification_type=Notification.LINK_DOMAIN,
|
||||||
|
read=False,
|
||||||
|
)
|
||||||
|
notification.related_link_domains.add(instance)
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
|
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
|
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
|
||||||
|
|
|
@ -171,7 +171,11 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
return
|
return
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
UserFollows.from_request(self)
|
try:
|
||||||
|
UserFollows.from_request(self)
|
||||||
|
except IntegrityError:
|
||||||
|
# this just means we already saved this relationship
|
||||||
|
pass
|
||||||
if self.id:
|
if self.id:
|
||||||
self.delete()
|
self.delete()
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
{% block panel %}{% endblock %}
|
{% block panel %}{% endblock %}
|
||||||
|
|
||||||
{% if activities %}
|
{% if activities %}
|
||||||
{% include 'snippets/pagination.html' with page=activities path=path anchor="#feed" %}
|
{% include 'snippets/pagination.html' with page=activities path=path anchor="#feed" mode="chronological" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,6 +17,8 @@
|
||||||
{% include 'notifications/items/add.html' %}
|
{% include 'notifications/items/add.html' %}
|
||||||
{% elif notification.notification_type == 'REPORT' %}
|
{% elif notification.notification_type == 'REPORT' %}
|
||||||
{% include 'notifications/items/report.html' %}
|
{% include 'notifications/items/report.html' %}
|
||||||
|
{% elif notification.notification_type == 'LINK_DOMAIN' %}
|
||||||
|
{% include 'notifications/items/link_domain.html' %}
|
||||||
{% elif notification.notification_type == 'INVITE' %}
|
{% elif notification.notification_type == 'INVITE' %}
|
||||||
{% include 'notifications/items/invite.html' %}
|
{% include 'notifications/items/invite.html' %}
|
||||||
{% elif notification.notification_type == 'ACCEPT' %}
|
{% elif notification.notification_type == 'ACCEPT' %}
|
||||||
|
|
20
bookwyrm/templates/notifications/items/link_domain.html
Normal file
20
bookwyrm/templates/notifications/items/link_domain.html
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends 'notifications/items/layout.html' %}
|
||||||
|
{% load humanize %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block primary_link %}{% spaceless %}
|
||||||
|
{% url 'settings-link-domain' %}
|
||||||
|
{% endspaceless %}{% endblock %}
|
||||||
|
|
||||||
|
{% block icon %}
|
||||||
|
<span class="icon icon-warning"></span>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block description %}
|
||||||
|
{% url 'settings-link-domain' as path %}
|
||||||
|
{% blocktrans trimmed count counter=notification.related_link_domains.count with display_count=notification.related_link_domains.count|intcomma %}
|
||||||
|
A new <a href="{{ path }}">link domain</a> needs review
|
||||||
|
{% plural %}
|
||||||
|
{{ display_count }} new <a href="{{ path }}">link domains</a> need moderation
|
||||||
|
{% endblocktrans %}
|
||||||
|
{% endblock %}
|
|
@ -9,7 +9,11 @@
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
|
|
||||||
<span class="icon icon-arrow-left" aria-hidden="true"></span>
|
<span class="icon icon-arrow-left" aria-hidden="true"></span>
|
||||||
{% trans "Older" %}
|
{% if mode == "chronological" %}
|
||||||
|
{% trans "Newer" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Previous" %}
|
||||||
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
|
@ -20,7 +24,11 @@
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
{% endif %}>
|
{% endif %}>
|
||||||
|
|
||||||
{% trans "Newer" %}
|
{% if mode == "chronological" %}
|
||||||
|
{% trans "Older" %}
|
||||||
|
{% else %}
|
||||||
|
{% trans "Next" %}
|
||||||
|
{% endif %}
|
||||||
<span class="icon icon-arrow-right" aria-hidden="true"></span>
|
<span class="icon icon-arrow-right" aria-hidden="true"></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,6 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include 'snippets/pagination.html' with page=activities path=path %}
|
{% include 'snippets/pagination.html' with page=activities path=path mode="chronological" %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -87,7 +87,7 @@
|
||||||
{% trans "Back" %}
|
{% trans "Back" %}
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="dropdown-menu">
|
<div class="dropdown-menu">
|
||||||
<ul
|
<ul
|
||||||
class="dropdown-content"
|
class="dropdown-content"
|
||||||
|
@ -130,7 +130,7 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% include 'snippets/pagination.html' with page=activities path=user.local_path anchor="#feed" %}
|
{% include 'snippets/pagination.html' with page=activities path=user.local_path anchor="#feed" mode="chronological" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -95,7 +95,8 @@ class Signature(TestCase):
|
||||||
|
|
||||||
def test_correct_signature(self):
|
def test_correct_signature(self):
|
||||||
"""this one should just work"""
|
"""this one should just work"""
|
||||||
response = self.send_test_request(sender=self.mouse)
|
with patch("bookwyrm.models.relationship.UserFollowRequest.accept"):
|
||||||
|
response = self.send_test_request(sender=self.mouse)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
def test_wrong_signature(self):
|
def test_wrong_signature(self):
|
||||||
|
@ -124,8 +125,12 @@ class Signature(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("bookwyrm.models.user.get_remote_reviews.delay"):
|
with patch("bookwyrm.models.user.get_remote_reviews.delay"):
|
||||||
response = self.send_test_request(sender=self.fake_remote)
|
with patch(
|
||||||
|
"bookwyrm.models.relationship.UserFollowRequest.accept"
|
||||||
|
) as accept_mock:
|
||||||
|
response = self.send_test_request(sender=self.fake_remote)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(accept_mock.called)
|
||||||
|
|
||||||
@responses.activate
|
@responses.activate
|
||||||
def test_key_needs_refresh(self):
|
def test_key_needs_refresh(self):
|
||||||
|
@ -148,16 +153,28 @@ class Signature(TestCase):
|
||||||
|
|
||||||
with patch("bookwyrm.models.user.get_remote_reviews.delay"):
|
with patch("bookwyrm.models.user.get_remote_reviews.delay"):
|
||||||
# Key correct:
|
# Key correct:
|
||||||
response = self.send_test_request(sender=self.fake_remote)
|
with patch(
|
||||||
|
"bookwyrm.models.relationship.UserFollowRequest.accept"
|
||||||
|
) as accept_mock:
|
||||||
|
response = self.send_test_request(sender=self.fake_remote)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(accept_mock.called)
|
||||||
|
|
||||||
# Old key is cached, so still works:
|
# Old key is cached, so still works:
|
||||||
response = self.send_test_request(sender=self.fake_remote)
|
with patch(
|
||||||
|
"bookwyrm.models.relationship.UserFollowRequest.accept"
|
||||||
|
) as accept_mock:
|
||||||
|
response = self.send_test_request(sender=self.fake_remote)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(accept_mock.called)
|
||||||
|
|
||||||
# Try with new key:
|
# Try with new key:
|
||||||
response = self.send_test_request(sender=new_sender)
|
with patch(
|
||||||
|
"bookwyrm.models.relationship.UserFollowRequest.accept"
|
||||||
|
) as accept_mock:
|
||||||
|
response = self.send_test_request(sender=new_sender)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(accept_mock.called)
|
||||||
|
|
||||||
# Now the old key will fail:
|
# Now the old key will fail:
|
||||||
response = self.send_test_request(sender=self.fake_remote)
|
response = self.send_test_request(sender=self.fake_remote)
|
||||||
|
|
|
@ -64,7 +64,7 @@ class Inbox(View):
|
||||||
high = ["Follow", "Accept", "Reject", "Block", "Unblock", "Undo"]
|
high = ["Follow", "Accept", "Reject", "Block", "Unblock", "Undo"]
|
||||||
|
|
||||||
priority = HIGH if activity_json["type"] in high else MEDIUM
|
priority = HIGH if activity_json["type"] in high else MEDIUM
|
||||||
activity_task.apply_async(args=(activity_json,), queue=priority)
|
sometimes_async_activity_task(activity_json, queue=priority)
|
||||||
return HttpResponse()
|
return HttpResponse()
|
||||||
|
|
||||||
|
|
||||||
|
@ -102,6 +102,19 @@ def raise_is_blocked_activity(activity_json):
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
|
def sometimes_async_activity_task(activity_json, queue=MEDIUM):
|
||||||
|
"""Sometimes we can effectively respond to a request without queuing a new task,
|
||||||
|
and whever that is possible, we should do it."""
|
||||||
|
activity = activitypub.parse(activity_json)
|
||||||
|
|
||||||
|
# try resolving this activity without making any http requests
|
||||||
|
try:
|
||||||
|
activity.action(allow_external_connections=False)
|
||||||
|
except activitypub.ActivitySerializerError:
|
||||||
|
# if that doesn't work, run it asynchronously
|
||||||
|
activity_task.apply_async(args=(activity_json,), queue=queue)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue=MEDIUM)
|
@app.task(queue=MEDIUM)
|
||||||
def activity_task(activity_json):
|
def activity_task(activity_json):
|
||||||
"""do something with this json we think is legit"""
|
"""do something with this json we think is legit"""
|
||||||
|
|
Loading…
Reference in a new issue