Merge branch 'main' into image-absolute-url-getter
This commit is contained in:
@ -14,7 +14,6 @@ from .status import Review, ReviewRating
|
||||
from .status import Boost
|
||||
from .attachment import Image
|
||||
from .favorite import Favorite
|
||||
from .notification import Notification
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
@ -22,10 +21,16 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||
from .report import Report, ReportComment
|
||||
from .federated_server import FederatedServer
|
||||
|
||||
from .group import Group, GroupMember, GroupMemberInvitation
|
||||
|
||||
from .import_job import ImportJob, ImportItem
|
||||
|
||||
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
|
||||
from .site import SiteSettings, SiteInvite
|
||||
from .site import PasswordReset, InviteRequest
|
||||
from .announcement import Announcement
|
||||
from .antispam import EmailBlocklist, IPBlocklist
|
||||
|
||||
from .notification import Notification
|
||||
|
||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_models = {
|
||||
|
@ -7,7 +7,7 @@ import operator
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
import requests
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Signature import pkcs1_15
|
||||
@ -43,7 +43,7 @@ class ActivitypubMixin:
|
||||
reverse_unfurl = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""collect some info on model fields"""
|
||||
"""collect some info on model fields for later use"""
|
||||
self.image_fields = []
|
||||
self.many_to_many_fields = []
|
||||
self.simple_fields = [] # "simple"
|
||||
@ -266,7 +266,7 @@ class ObjectMixin(ActivitypubMixin):
|
||||
signed_message = signer.sign(SHA256.new(content.encode("utf8")))
|
||||
|
||||
signature = activitypub.Signature(
|
||||
creator="%s#main-key" % user.remote_id,
|
||||
creator=f"{user.remote_id}#main-key",
|
||||
created=activity_object.published,
|
||||
signatureValue=b64encode(signed_message).decode("utf8"),
|
||||
)
|
||||
@ -285,16 +285,16 @@ class ObjectMixin(ActivitypubMixin):
|
||||
return activitypub.Delete(
|
||||
id=self.remote_id + "/activity",
|
||||
actor=user.remote_id,
|
||||
to=["%s/followers" % user.remote_id],
|
||||
to=[f"{user.remote_id}/followers"],
|
||||
cc=["https://www.w3.org/ns/activitystreams#Public"],
|
||||
object=self,
|
||||
).serialize()
|
||||
|
||||
def to_update_activity(self, user):
|
||||
"""wrapper for Updates to an activity"""
|
||||
activity_id = "%s#update/%s" % (self.remote_id, uuid4())
|
||||
uuid = uuid4()
|
||||
return activitypub.Update(
|
||||
id=activity_id,
|
||||
id=f"{self.remote_id}#update/{uuid}",
|
||||
actor=user.remote_id,
|
||||
to=["https://www.w3.org/ns/activitystreams#Public"],
|
||||
object=self,
|
||||
@ -337,8 +337,8 @@ class OrderedCollectionPageMixin(ObjectMixin):
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
# add computed fields specific to orderd collections
|
||||
activity["totalItems"] = paginated.count
|
||||
activity["first"] = "%s?page=1" % remote_id
|
||||
activity["last"] = "%s?page=%d" % (remote_id, paginated.num_pages)
|
||||
activity["first"] = f"{remote_id}?page=1"
|
||||
activity["last"] = f"{remote_id}?page={paginated.num_pages}"
|
||||
|
||||
return serializer(**activity)
|
||||
|
||||
@ -362,6 +362,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
||||
self.collection_queryset, **kwargs
|
||||
).serialize()
|
||||
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
"""Delete the object"""
|
||||
activity = self.to_delete_activity(self.user)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.user.local and broadcast:
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
|
||||
class CollectionItemMixin(ActivitypubMixin):
|
||||
"""for items that are part of an (Ordered)Collection"""
|
||||
@ -413,7 +420,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
||||
"""AP for shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Add(
|
||||
id="{:s}#add".format(collection_field.remote_id),
|
||||
id=f"{collection_field.remote_id}#add",
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
@ -423,7 +430,7 @@ class CollectionItemMixin(ActivitypubMixin):
|
||||
"""AP for un-shelving a book"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Remove(
|
||||
id="{:s}#remove".format(collection_field.remote_id),
|
||||
id=f"{collection_field.remote_id}#remove",
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
@ -451,7 +458,7 @@ class ActivityMixin(ActivitypubMixin):
|
||||
"""undo an action"""
|
||||
user = self.user if hasattr(self, "user") else self.user_subject
|
||||
return activitypub.Undo(
|
||||
id="%s#undo" % self.remote_id,
|
||||
id=f"{self.remote_id}#undo",
|
||||
actor=user.remote_id,
|
||||
object=self,
|
||||
).serialize()
|
||||
@ -495,7 +502,7 @@ def unfurl_related_field(related_field, sort_field=None):
|
||||
return related_field.remote_id
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="medium_priority")
|
||||
def broadcast_task(sender_id, activity, recipients):
|
||||
"""the celery task for broadcast"""
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
@ -503,7 +510,7 @@ def broadcast_task(sender_id, activity, recipients):
|
||||
for recipient in recipients:
|
||||
try:
|
||||
sign_and_send(sender, activity, recipient)
|
||||
except (HTTPError, SSLError, requests.exceptions.ConnectionError):
|
||||
except RequestException:
|
||||
pass
|
||||
|
||||
|
||||
@ -548,11 +555,11 @@ def to_ordered_collection_page(
|
||||
|
||||
prev_page = next_page = None
|
||||
if activity_page.has_next():
|
||||
next_page = "%s?page=%d" % (remote_id, activity_page.next_page_number())
|
||||
next_page = f"{remote_id}?page={activity_page.next_page_number()}"
|
||||
if activity_page.has_previous():
|
||||
prev_page = "%s?page=%d" % (remote_id, activity_page.previous_page_number())
|
||||
prev_page = f"{remote_id}?page=%d{activity_page.previous_page_number()}"
|
||||
return activitypub.OrderedCollectionPage(
|
||||
id="%s?page=%s" % (remote_id, page),
|
||||
id=f"{remote_id}?page={page}",
|
||||
partOf=remote_id,
|
||||
orderedItems=items,
|
||||
next=next_page,
|
||||
|
35
bookwyrm/models/antispam.py
Normal file
35
bookwyrm/models/antispam.py
Normal file
@ -0,0 +1,35 @@
|
||||
""" Lets try NOT to sell viagra """
|
||||
from django.db import models
|
||||
|
||||
from .user import User
|
||||
|
||||
|
||||
class EmailBlocklist(models.Model):
|
||||
"""blocked email addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
domain = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-created_date",)
|
||||
|
||||
@property
|
||||
def users(self):
|
||||
"""find the users associated with this address"""
|
||||
return User.objects.filter(email__endswith=f"@{self.domain}")
|
||||
|
||||
|
||||
class IPBlocklist(models.Model):
|
||||
"""blocked ip addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
address = models.CharField(max_length=255, unique=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
||||
ordering = ("-created_date",)
|
@ -35,7 +35,7 @@ class Author(BookDataModel):
|
||||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/author/%s" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/author/{self.id}"
|
||||
|
||||
activity_serializer = activitypub.Author
|
||||
|
||||
|
@ -1,19 +1,30 @@
|
||||
""" base model with default fields """
|
||||
import base64
|
||||
from Crypto import Random
|
||||
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .fields import RemoteIdField
|
||||
|
||||
|
||||
DeactivationReason = models.TextChoices(
|
||||
"DeactivationReason",
|
||||
[
|
||||
"self_deletion",
|
||||
"moderator_deletion",
|
||||
"domain_block",
|
||||
],
|
||||
)
|
||||
DeactivationReason = [
|
||||
("pending", _("Pending")),
|
||||
("self_deletion", _("Self deletion")),
|
||||
("moderator_suspension", _("Moderator suspension")),
|
||||
("moderator_deletion", _("Moderator deletion")),
|
||||
("domain_block", _("Domain block")),
|
||||
]
|
||||
|
||||
|
||||
def new_access_code():
|
||||
"""the identifier for a user invite"""
|
||||
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
|
||||
|
||||
|
||||
class BookWyrmModel(models.Model):
|
||||
@ -25,11 +36,11 @@ class BookWyrmModel(models.Model):
|
||||
|
||||
def get_remote_id(self):
|
||||
"""generate a url that resolves to the local object"""
|
||||
base_path = "https://%s" % DOMAIN
|
||||
base_path = f"https://{DOMAIN}"
|
||||
if hasattr(self, "user"):
|
||||
base_path = "%s%s" % (base_path, self.user.local_path)
|
||||
base_path = f"{base_path}{self.user.local_path}"
|
||||
model_name = type(self).__name__.lower()
|
||||
return "%s/%s/%d" % (base_path, model_name, self.id)
|
||||
return f"{base_path}/{model_name}/{self.id}"
|
||||
|
||||
class Meta:
|
||||
"""this is just here to provide default fields for other models"""
|
||||
@ -39,37 +50,123 @@ class BookWyrmModel(models.Model):
|
||||
@property
|
||||
def local_path(self):
|
||||
"""how to link to this object in the local app"""
|
||||
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
||||
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||
|
||||
def visible_to_user(self, viewer):
|
||||
def raise_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
|
||||
return
|
||||
|
||||
# viewer can't see it if the object's owner blocked them
|
||||
if viewer in self.user.blocks.all():
|
||||
return False
|
||||
raise Http404()
|
||||
|
||||
# you can see your own posts and any public or unlisted posts
|
||||
if viewer == self.user or self.privacy in ["public", "unlisted"]:
|
||||
return True
|
||||
return
|
||||
|
||||
# you can see the followers only posts of people you follow
|
||||
if (
|
||||
self.privacy == "followers"
|
||||
and self.user.followers.filter(id=viewer.id).first()
|
||||
if self.privacy == "followers" and (
|
||||
self.user.followers.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return
|
||||
|
||||
# you can see dms you are tagged in
|
||||
if hasattr(self, "mention_users"):
|
||||
if (
|
||||
self.privacy == "direct"
|
||||
self.privacy in ["direct", "followers"]
|
||||
and self.mention_users.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
return
|
||||
|
||||
# you can see groups of which you are a member
|
||||
if (
|
||||
hasattr(self, "memberships")
|
||||
and self.memberships.filter(user=viewer).exists()
|
||||
):
|
||||
return
|
||||
|
||||
# you can see objects which have a group of which you are a member
|
||||
if hasattr(self, "group"):
|
||||
if (
|
||||
hasattr(self.group, "memberships")
|
||||
and self.group.memberships.filter(user=viewer).exists()
|
||||
):
|
||||
return
|
||||
|
||||
raise Http404()
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""does this user have permission to edit this object? liable to be overwritten
|
||||
by models that inherit this base model class"""
|
||||
if not hasattr(self, "user"):
|
||||
return
|
||||
|
||||
# generally moderators shouldn't be able to edit other people's stuff
|
||||
if self.user == viewer:
|
||||
return
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""does this user have permission to delete this object? liable to be
|
||||
overwritten by models that inherit this base model class"""
|
||||
if not hasattr(self, "user"):
|
||||
return
|
||||
|
||||
# but generally moderators can delete other people's stuff
|
||||
if self.user == viewer or viewer.has_perm("moderate_post"):
|
||||
return
|
||||
|
||||
raise PermissionDenied()
|
||||
|
||||
@classmethod
|
||||
def privacy_filter(cls, viewer, privacy_levels=None):
|
||||
"""filter objects that have "user" and "privacy" fields"""
|
||||
queryset = cls.objects
|
||||
if hasattr(queryset, "select_subclasses"):
|
||||
queryset = queryset.select_subclasses()
|
||||
|
||||
privacy_levels = privacy_levels or ["public", "unlisted", "followers", "direct"]
|
||||
# you can't see followers only or direct messages if you're not logged in
|
||||
if viewer.is_anonymous:
|
||||
privacy_levels = [
|
||||
p for p in privacy_levels if not p in ["followers", "direct"]
|
||||
]
|
||||
else:
|
||||
# exclude blocks from both directions
|
||||
queryset = queryset.exclude(
|
||||
Q(user__blocked_by=viewer) | Q(user__blocks=viewer)
|
||||
)
|
||||
|
||||
# filter to only provided privacy levels
|
||||
queryset = queryset.filter(privacy__in=privacy_levels)
|
||||
|
||||
if "followers" in privacy_levels:
|
||||
queryset = cls.followers_filter(queryset, viewer)
|
||||
|
||||
# exclude direct messages not intended for the user
|
||||
if "direct" in privacy_levels:
|
||||
queryset = cls.direct_filter(queryset, viewer)
|
||||
|
||||
return queryset
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override-able filter for "followers" privacy level"""
|
||||
return queryset.exclude(
|
||||
~Q( # user isn't following and it isn't their own status
|
||||
Q(user__followers=viewer) | Q(user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override-able filter for "direct" privacy level"""
|
||||
return queryset.exclude(~Q(user=viewer), privacy="direct")
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
|
@ -3,14 +3,22 @@ import re
|
||||
|
||||
from django.contrib.postgres.search import SearchVectorField
|
||||
from django.contrib.postgres.indexes import GinIndex
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils import FieldTracker
|
||||
from model_utils.managers import InheritanceManager
|
||||
from imagekit.models import ImageSpecField
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.preview_images import generate_edition_preview_image_task
|
||||
from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES
|
||||
from bookwyrm.settings import (
|
||||
DOMAIN,
|
||||
DEFAULT_LANGUAGE,
|
||||
ENABLE_PREVIEW_IMAGES,
|
||||
ENABLE_THUMBNAIL_GENERATION,
|
||||
)
|
||||
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
|
||||
from .base_model import BookWyrmModel
|
||||
@ -97,6 +105,40 @@ class Book(BookDataModel):
|
||||
objects = InheritanceManager()
|
||||
field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
|
||||
|
||||
if ENABLE_THUMBNAIL_GENERATION:
|
||||
cover_bw_book_xsmall_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:xsmall:webp"
|
||||
)
|
||||
cover_bw_book_xsmall_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:xsmall:jpg"
|
||||
)
|
||||
cover_bw_book_small_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:small:webp"
|
||||
)
|
||||
cover_bw_book_small_jpg = ImageSpecField(source="cover", id="bw:book:small:jpg")
|
||||
cover_bw_book_medium_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:medium:webp"
|
||||
)
|
||||
cover_bw_book_medium_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:medium:jpg"
|
||||
)
|
||||
cover_bw_book_large_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:large:webp"
|
||||
)
|
||||
cover_bw_book_large_jpg = ImageSpecField(source="cover", id="bw:book:large:jpg")
|
||||
cover_bw_book_xlarge_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:xlarge:webp"
|
||||
)
|
||||
cover_bw_book_xlarge_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:xlarge:jpg"
|
||||
)
|
||||
cover_bw_book_xxlarge_webp = ImageSpecField(
|
||||
source="cover", id="bw:book:xxlarge:webp"
|
||||
)
|
||||
cover_bw_book_xxlarge_jpg = ImageSpecField(
|
||||
source="cover", id="bw:book:xxlarge:jpg"
|
||||
)
|
||||
|
||||
@property
|
||||
def author_text(self):
|
||||
"""format a list of authors"""
|
||||
@ -123,9 +165,9 @@ class Book(BookDataModel):
|
||||
@property
|
||||
def alt_text(self):
|
||||
"""image alt test"""
|
||||
text = "%s" % self.title
|
||||
text = self.title
|
||||
if self.edition_info:
|
||||
text += " (%s)" % self.edition_info
|
||||
text += f" ({self.edition_info})"
|
||||
return text
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -136,9 +178,10 @@ class Book(BookDataModel):
|
||||
|
||||
def get_remote_id(self):
|
||||
"""editions and works both use "book" instead of model_name"""
|
||||
return "https://%s/book/%d" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/book/{self.id}"
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<{} key={!r} title={!r}>".format(
|
||||
self.__class__,
|
||||
self.openlibrary_key,
|
||||
@ -175,7 +218,7 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||
"""an ordered collection of editions"""
|
||||
return self.to_ordered_collection(
|
||||
self.editions.order_by("-edition_rank").all(),
|
||||
remote_id="%s/editions" % self.remote_id,
|
||||
remote_id=f"{self.remote_id}/editions",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@ -184,6 +227,16 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||
deserialize_reverse_fields = [("editions", "editions")]
|
||||
|
||||
|
||||
# https://schema.org/BookFormatType
|
||||
FormatChoices = [
|
||||
("AudiobookFormat", _("Audiobook")),
|
||||
("EBook", _("eBook")),
|
||||
("GraphicNovel", _("Graphic novel")),
|
||||
("Hardcover", _("Hardcover")),
|
||||
("Paperback", _("Paperback")),
|
||||
]
|
||||
|
||||
|
||||
class Edition(Book):
|
||||
"""an edition of a book"""
|
||||
|
||||
@ -201,7 +254,10 @@ class Edition(Book):
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
pages = fields.IntegerField(blank=True, null=True)
|
||||
physical_format = fields.CharField(max_length=255, blank=True, null=True)
|
||||
physical_format = fields.CharField(
|
||||
max_length=255, choices=FormatChoices, null=True, blank=True
|
||||
)
|
||||
physical_format_detail = fields.CharField(max_length=255, blank=True, null=True)
|
||||
publishers = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
@ -265,6 +321,27 @@ class Edition(Book):
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def viewer_aware_objects(cls, viewer):
|
||||
"""annotate a book query with metadata related to the user"""
|
||||
queryset = cls.objects
|
||||
if not viewer or not viewer.is_authenticated:
|
||||
return queryset
|
||||
|
||||
queryset = queryset.prefetch_related(
|
||||
Prefetch(
|
||||
"shelfbook_set",
|
||||
queryset=viewer.shelfbook_set.all(),
|
||||
to_attr="current_shelves",
|
||||
),
|
||||
Prefetch(
|
||||
"readthrough_set",
|
||||
queryset=viewer.readthrough_set.filter(is_active=True).all(),
|
||||
to_attr="active_readthroughs",
|
||||
),
|
||||
)
|
||||
return queryset
|
||||
|
||||
|
||||
def isbn_10_to_13(isbn_10):
|
||||
"""convert an isbn 10 into an isbn 13"""
|
||||
@ -321,4 +398,6 @@ def preview_image(instance, *args, **kwargs):
|
||||
changed_fields = instance.field_tracker.changed()
|
||||
|
||||
if len(changed_fields) > 0:
|
||||
generate_edition_preview_image_task.delay(instance.id)
|
||||
transaction.on_commit(
|
||||
lambda: generate_edition_preview_image_task.delay(instance.id)
|
||||
)
|
||||
|
@ -14,12 +14,11 @@ class Connector(BookWyrmModel):
|
||||
identifier = models.CharField(max_length=255, unique=True)
|
||||
priority = models.IntegerField(default=2)
|
||||
name = models.CharField(max_length=255, null=True, blank=True)
|
||||
local = models.BooleanField(default=False)
|
||||
connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices)
|
||||
api_key = models.CharField(max_length=255, null=True, blank=True)
|
||||
active = models.BooleanField(default=True)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
|
||||
base_url = models.CharField(max_length=255)
|
||||
@ -29,7 +28,4 @@ class Connector(BookWyrmModel):
|
||||
isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return "{} ({})".format(
|
||||
self.identifier,
|
||||
self.id,
|
||||
)
|
||||
return f"{self.identifier} ({self.id})"
|
||||
|
@ -1,7 +1,5 @@
|
||||
""" like/fav/star a status """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .activitypub_mixin import ActivityMixin
|
||||
@ -29,38 +27,9 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
self.user.update_active_date()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if self.status.user.local and self.status.user != self.user:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification_model.objects.create(
|
||||
user=self.status.user,
|
||||
notification_type="FAVORITE",
|
||||
related_user=self.user,
|
||||
related_status=self.status,
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""delete and delete notifications"""
|
||||
# check for notification
|
||||
if self.status.user.local:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification = notification_model.objects.filter(
|
||||
user=self.status.user,
|
||||
related_user=self.user,
|
||||
related_status=self.status,
|
||||
notification_type="FAVORITE",
|
||||
).first()
|
||||
if notification:
|
||||
notification.delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
"""can't fav things twice"""
|
||||
|
||||
|
@ -1,16 +1,16 @@
|
||||
""" connections to external ActivityPub servers """
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
FederationStatus = models.TextChoices(
|
||||
"Status",
|
||||
[
|
||||
"federated",
|
||||
"blocked",
|
||||
],
|
||||
)
|
||||
FederationStatus = [
|
||||
("federated", _("Federated")),
|
||||
("blocked", _("Blocked")),
|
||||
]
|
||||
|
||||
|
||||
class FederatedServer(BookWyrmModel):
|
||||
@ -18,7 +18,7 @@ class FederatedServer(BookWyrmModel):
|
||||
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
status = models.CharField(
|
||||
max_length=255, default="federated", choices=FederationStatus.choices
|
||||
max_length=255, default="federated", choices=FederationStatus
|
||||
)
|
||||
# is it mastodon, bookwyrm, etc
|
||||
application_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
@ -28,7 +28,7 @@ class FederatedServer(BookWyrmModel):
|
||||
def block(self):
|
||||
"""block a server"""
|
||||
self.status = "blocked"
|
||||
self.save()
|
||||
self.save(update_fields=["status"])
|
||||
|
||||
# deactivate all associated users
|
||||
self.user_set.filter(is_active=True).update(
|
||||
@ -45,7 +45,7 @@ class FederatedServer(BookWyrmModel):
|
||||
def unblock(self):
|
||||
"""unblock a server"""
|
||||
self.status = "federated"
|
||||
self.save()
|
||||
self.save(update_fields=["status"])
|
||||
|
||||
self.user_set.filter(deactivation_reason="domain_block").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
|
@ -58,7 +58,7 @@ class ActivitypubFieldMixin:
|
||||
activitypub_field=None,
|
||||
activitypub_wrapper=None,
|
||||
deduplication_field=False,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
self.deduplication_field = deduplication_field
|
||||
if activitypub_wrapper:
|
||||
@ -68,8 +68,8 @@ class ActivitypubFieldMixin:
|
||||
self.activitypub_field = activitypub_field
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
"""helper function for assinging a value to the field"""
|
||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
||||
"""helper function for assinging a value to the field. Returns if changed"""
|
||||
try:
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
except AttributeError:
|
||||
@ -79,8 +79,21 @@ class ActivitypubFieldMixin:
|
||||
value = getattr(data, "actor")
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING or formatted == {}:
|
||||
return
|
||||
return False
|
||||
|
||||
current_value = (
|
||||
getattr(instance, self.name) if hasattr(instance, self.name) else None
|
||||
)
|
||||
# if we're not in overwrite mode, only continue updating the field if its unset
|
||||
if current_value and not overwrite:
|
||||
return False
|
||||
|
||||
# the field is unchanged
|
||||
if current_value == formatted:
|
||||
return False
|
||||
|
||||
setattr(instance, self.name, formatted)
|
||||
return True
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
"""update the json object"""
|
||||
@ -206,17 +219,34 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def set_field_from_activity(self, instance, data):
|
||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
||||
if not overwrite:
|
||||
return False
|
||||
|
||||
original = getattr(instance, self.name)
|
||||
to = data.to
|
||||
cc = data.cc
|
||||
|
||||
# we need to figure out who this is to get their followers link
|
||||
for field in ["attributedTo", "owner", "actor"]:
|
||||
if hasattr(data, field):
|
||||
user_field = field
|
||||
break
|
||||
if not user_field:
|
||||
raise ValidationError("No user field found for privacy", data)
|
||||
user = activitypub.resolve_remote_id(getattr(data, user_field), model="User")
|
||||
|
||||
if to == [self.public]:
|
||||
setattr(instance, self.name, "public")
|
||||
elif to == [user.followers_url]:
|
||||
setattr(instance, self.name, "followers")
|
||||
elif cc == []:
|
||||
setattr(instance, self.name, "direct")
|
||||
elif self.public in cc:
|
||||
setattr(instance, self.name, "unlisted")
|
||||
else:
|
||||
setattr(instance, self.name, "followers")
|
||||
return original == getattr(instance, self.name)
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
# explicitly to anyone mentioned (statuses only)
|
||||
@ -225,9 +255,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||
mentions = [u.remote_id for u in instance.mention_users.all()]
|
||||
# this is a link to the followers list
|
||||
# pylint: disable=protected-access
|
||||
followers = instance.user.__class__._meta.get_field(
|
||||
"followers"
|
||||
).field_to_activity(instance.user.followers)
|
||||
followers = instance.user.followers_url
|
||||
if instance.privacy == "public":
|
||||
activity["to"] = [self.public]
|
||||
activity["cc"] = [followers] + mentions
|
||||
@ -267,18 +295,22 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||
self.link_only = link_only
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
def set_field_from_activity(self, instance, data, overwrite=True):
|
||||
"""helper function for assinging a value to the field"""
|
||||
if not overwrite and getattr(instance, self.name).exists():
|
||||
return False
|
||||
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING:
|
||||
return
|
||||
return False
|
||||
getattr(instance, self.name).set(formatted)
|
||||
instance.save(broadcast=False)
|
||||
return True
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if self.link_only:
|
||||
return "%s/%s" % (value.instance.remote_id, self.name)
|
||||
return f"{value.instance.remote_id}/{self.name}"
|
||||
return [i.remote_id for i in value.all()]
|
||||
|
||||
def field_from_activity(self, value):
|
||||
@ -368,13 +400,18 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def set_field_from_activity(self, instance, data, save=True):
|
||||
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
|
||||
"""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:
|
||||
return
|
||||
return False
|
||||
|
||||
if not overwrite and hasattr(instance, self.name):
|
||||
return False
|
||||
|
||||
getattr(instance, self.name).save(*formatted, save=save)
|
||||
return True
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
value = getattr(instance, self.name)
|
||||
@ -412,7 +449,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||
|
||||
image_content = ContentFile(response.content)
|
||||
extension = imghdr.what(None, image_content.read()) or ""
|
||||
image_name = "{:s}.{:s}".format(str(uuid4()), extension)
|
||||
image_name = f"{uuid4()}.{extension}"
|
||||
return [image_name, image_content]
|
||||
|
||||
def formfield(self, **kwargs):
|
||||
|
182
bookwyrm/models/group.py
Normal file
182
bookwyrm/models/group.py
Normal file
@ -0,0 +1,182 @@
|
||||
""" do book related things with other users """
|
||||
from django.apps import apps
|
||||
from django.db import models, IntegrityError, transaction
|
||||
from django.db.models import Q
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
from .relationship import UserBlocks
|
||||
|
||||
|
||||
class Group(BookWyrmModel):
|
||||
"""A group of users"""
|
||||
|
||||
name = fields.CharField(max_length=100)
|
||||
user = fields.ForeignKey("User", on_delete=models.CASCADE)
|
||||
description = fields.TextField(blank=True, null=True)
|
||||
privacy = fields.PrivacyField()
|
||||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return f"https://{DOMAIN}/group/{self.id}"
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override filter for "followers" privacy level to allow non-following
|
||||
group members to see the existence of groups and group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user is not a group member
|
||||
Q(user__followers=viewer) | Q(user=viewer) | Q(memberships__user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status of the group is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override filter for "direct" privacy level to allow group members
|
||||
to see the existence of groups and group lists"""
|
||||
|
||||
return queryset.exclude(~Q(memberships__user=viewer), privacy="direct")
|
||||
|
||||
|
||||
class GroupMember(models.Model):
|
||||
"""Users who are members of a group"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, related_name="memberships"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, related_name="memberships"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Users can only have one membership per group"""
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["group", "user"], name="unique_membership")
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""don't let a user invite someone who blocked them"""
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.group.user,
|
||||
user_object=self.user,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user,
|
||||
user_object=self.group.user,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
# accepts and requests are handled by the GroupInvitation model
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def from_request(cls, join_request):
|
||||
"""converts a join request into a member relationship"""
|
||||
|
||||
# remove the invite
|
||||
join_request.delete()
|
||||
|
||||
# make a group member
|
||||
return cls.objects.create(
|
||||
user=join_request.user,
|
||||
group=join_request.group,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove(cls, owner, user):
|
||||
"""remove a user from a group"""
|
||||
|
||||
memberships = cls.objects.filter(group__user=owner, user=user).all()
|
||||
for member in memberships:
|
||||
member.delete()
|
||||
|
||||
|
||||
class GroupMemberInvitation(models.Model):
|
||||
"""adding a user to a group requires manual confirmation"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, related_name="user_invitations"
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, related_name="group_invitations"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""Users can only have one outstanding invitation per group"""
|
||||
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["group", "user"], name="unique_invitation")
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""make sure the membership doesn't already exist"""
|
||||
# if there's an invitation for a membership that already exists, accept it
|
||||
# without changing the local database state
|
||||
if GroupMember.objects.filter(user=self.user, group=self.group).exists():
|
||||
self.accept()
|
||||
return
|
||||
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
user_subject=self.group.user,
|
||||
user_object=self.user,
|
||||
)
|
||||
| Q(
|
||||
user_subject=self.user,
|
||||
user_object=self.group.user,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
|
||||
# make an invitation
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# now send the invite
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_type = "INVITE"
|
||||
model.objects.create(
|
||||
user=self.user,
|
||||
related_user=self.group.user,
|
||||
related_group=self.group,
|
||||
notification_type=notification_type,
|
||||
)
|
||||
|
||||
def accept(self):
|
||||
"""turn this request into the real deal"""
|
||||
|
||||
with transaction.atomic():
|
||||
GroupMember.from_request(self)
|
||||
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
# tell the group owner
|
||||
model.objects.create(
|
||||
user=self.group.user,
|
||||
related_user=self.user,
|
||||
related_group=self.group,
|
||||
notification_type="ACCEPT",
|
||||
)
|
||||
|
||||
# let the other members know about it
|
||||
for membership in self.group.memberships.all():
|
||||
member = membership.user
|
||||
if member not in (self.user, self.group.user):
|
||||
model.objects.create(
|
||||
user=member,
|
||||
related_user=self.user,
|
||||
related_group=self.group,
|
||||
notification_type="JOIN",
|
||||
)
|
||||
|
||||
def reject(self):
|
||||
"""generate a Reject for this membership request"""
|
||||
|
||||
self.delete()
|
@ -2,7 +2,6 @@
|
||||
import re
|
||||
import dateutil.parser
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
@ -50,19 +49,6 @@ class ImportJob(models.Model):
|
||||
)
|
||||
retry = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save and notify"""
|
||||
super().save(*args, **kwargs)
|
||||
if self.complete:
|
||||
notification_model = apps.get_model(
|
||||
"bookwyrm.Notification", require_ready=True
|
||||
)
|
||||
notification_model.objects.create(
|
||||
user=self.user,
|
||||
notification_type="IMPORT",
|
||||
related_import=self,
|
||||
)
|
||||
|
||||
|
||||
class ImportItem(models.Model):
|
||||
"""a single line of a csv being imported"""
|
||||
@ -71,6 +57,13 @@ class ImportItem(models.Model):
|
||||
index = models.IntegerField()
|
||||
data = models.JSONField()
|
||||
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
book_guess = models.ForeignKey(
|
||||
Book,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="book_guess",
|
||||
)
|
||||
fail_reason = models.TextField(null=True)
|
||||
|
||||
def resolve(self):
|
||||
@ -78,9 +71,13 @@ class ImportItem(models.Model):
|
||||
if self.isbn:
|
||||
self.book = self.get_book_from_isbn()
|
||||
else:
|
||||
# don't fall back on title/author search is isbn is present.
|
||||
# don't fall back on title/author search if isbn is present.
|
||||
# you're too likely to mismatch
|
||||
self.get_book_from_title_author()
|
||||
book, confidence = self.get_book_from_title_author()
|
||||
if confidence > 0.999:
|
||||
self.book = book
|
||||
else:
|
||||
self.book_guess = book
|
||||
|
||||
def get_book_from_isbn(self):
|
||||
"""search by isbn"""
|
||||
@ -96,12 +93,15 @@ class ImportItem(models.Model):
|
||||
"""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
|
||||
search_term, min_confidence=0.1
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
return (
|
||||
search_result.connector.get_or_create_book(search_result.key),
|
||||
search_result.confidence,
|
||||
)
|
||||
return None, 0
|
||||
|
||||
@property
|
||||
def title(self):
|
||||
@ -174,6 +174,7 @@ class ImportItem(models.Model):
|
||||
if start_date and start_date is not None and not self.date_read:
|
||||
return [ReadThrough(start_date=start_date)]
|
||||
if self.date_read:
|
||||
start_date = start_date if start_date < self.date_read else None
|
||||
return [
|
||||
ReadThrough(
|
||||
start_date=start_date,
|
||||
@ -183,7 +184,9 @@ class ImportItem(models.Model):
|
||||
return []
|
||||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
|
||||
|
||||
def __str__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "{} by {}".format(self.data["Title"], self.data["Author"])
|
||||
|
@ -1,22 +1,20 @@
|
||||
""" make a list of books!! """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from .group import GroupMember
|
||||
from . import fields
|
||||
|
||||
|
||||
CurationType = models.TextChoices(
|
||||
"Curation",
|
||||
[
|
||||
"closed",
|
||||
"open",
|
||||
"curated",
|
||||
],
|
||||
["closed", "open", "curated", "group"],
|
||||
)
|
||||
|
||||
|
||||
@ -32,6 +30,13 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
||||
curation = fields.CharField(
|
||||
max_length=255, default="closed", choices=CurationType.choices
|
||||
)
|
||||
group = models.ForeignKey(
|
||||
"Group",
|
||||
on_delete=models.SET_NULL,
|
||||
default=None,
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
books = models.ManyToManyField(
|
||||
"Edition",
|
||||
symmetrical=False,
|
||||
@ -42,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
||||
|
||||
def get_remote_id(self):
|
||||
"""don't want the user to be in there in this case"""
|
||||
return "https://%s/list/%d" % (DOMAIN, self.id)
|
||||
return f"https://{DOMAIN}/list/{self.id}"
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
@ -54,6 +59,52 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
||||
|
||||
ordering = ("-updated_date",)
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""the associated user OR the list owner can edit"""
|
||||
if self.user == viewer:
|
||||
return
|
||||
# group members can edit items in group lists
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.group, user=viewer
|
||||
).exists()
|
||||
if is_group_member:
|
||||
return
|
||||
super().raise_not_editable(viewer)
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override filter for "followers" privacy level to allow non-following
|
||||
group members to see the existence of group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user isn't following or group member
|
||||
Q(user__followers=viewer)
|
||||
| Q(user=viewer)
|
||||
| Q(group__memberships__user=viewer)
|
||||
),
|
||||
privacy="followers", # and the status (of the list) is followers only
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Override filter for "direct" privacy level to allow
|
||||
group members to see the existence of group lists"""
|
||||
|
||||
return queryset.exclude(
|
||||
~Q( # user not self and not in the group if this is a group list
|
||||
Q(user=viewer) | Q(group__memberships__user=viewer)
|
||||
),
|
||||
privacy="direct",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def remove_from_group(cls, owner, user):
|
||||
"""remove a list from a group"""
|
||||
|
||||
cls.objects.filter(group__user=owner, user=user).all().update(
|
||||
group=None, curation="closed"
|
||||
)
|
||||
|
||||
|
||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
"""ok"""
|
||||
@ -82,9 +133,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
self.book_list.save(broadcast=False)
|
||||
|
||||
list_owner = self.book_list.user
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
# create a notification if somoene ELSE added to a local user's list
|
||||
if created and list_owner.local and list_owner != self.user:
|
||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
model.objects.create(
|
||||
user=list_owner,
|
||||
related_user=self.user,
|
||||
@ -92,6 +143,28 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||
notification_type="ADD",
|
||||
)
|
||||
|
||||
if self.book_list.group:
|
||||
for membership in self.book_list.group.memberships.all():
|
||||
if membership.user != self.user:
|
||||
model.objects.create(
|
||||
user=membership.user,
|
||||
related_user=self.user,
|
||||
related_list_item=self,
|
||||
notification_type="ADD",
|
||||
)
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""the associated user OR the list owner can delete"""
|
||||
if self.book_list.user == viewer:
|
||||
return
|
||||
# group members can delete items in group lists
|
||||
is_group_member = GroupMember.objects.filter(
|
||||
group=self.book_list.group, user=viewer
|
||||
).exists()
|
||||
if is_group_member:
|
||||
return
|
||||
super().raise_not_deletable(viewer)
|
||||
|
||||
class Meta:
|
||||
"""A book may only be placed into a list once,
|
||||
and each order in the list may be used only once"""
|
||||
|
@ -1,11 +1,13 @@
|
||||
""" alert a user to activity """
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
from .base_model import BookWyrmModel
|
||||
from . import Boost, Favorite, ImportJob, Report, Status, User
|
||||
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
NotificationType = models.TextChoices(
|
||||
"NotificationType",
|
||||
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT",
|
||||
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE",
|
||||
)
|
||||
|
||||
|
||||
@ -17,6 +19,9 @@ class Notification(BookWyrmModel):
|
||||
related_user = models.ForeignKey(
|
||||
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
|
||||
)
|
||||
related_group = models.ForeignKey(
|
||||
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
|
||||
)
|
||||
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
|
||||
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
|
||||
related_list_item = models.ForeignKey(
|
||||
@ -35,6 +40,7 @@ class Notification(BookWyrmModel):
|
||||
user=self.user,
|
||||
related_book=self.related_book,
|
||||
related_user=self.related_user,
|
||||
related_group=self.related_group,
|
||||
related_status=self.related_status,
|
||||
related_import=self.related_import,
|
||||
related_list_item=self.related_list_item,
|
||||
@ -53,3 +59,127 @@ class Notification(BookWyrmModel):
|
||||
name="notification_type_valid",
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Favorite)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_on_fav(sender, instance, *args, **kwargs):
|
||||
"""someone liked your content, you ARE loved"""
|
||||
if not instance.status.user.local or instance.status.user == instance.user:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.status.user,
|
||||
notification_type="FAVORITE",
|
||||
related_user=instance.user,
|
||||
related_status=instance.status,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Favorite)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_on_unfav(sender, instance, *args, **kwargs):
|
||||
"""oops, didn't like that after all"""
|
||||
if not instance.status.user.local:
|
||||
return
|
||||
Notification.objects.filter(
|
||||
user=instance.status.user,
|
||||
related_user=instance.user,
|
||||
related_status=instance.status,
|
||||
notification_type="FAVORITE",
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_mention(sender, instance, *args, **kwargs):
|
||||
"""creating and deleting statuses with @ mentions and replies"""
|
||||
if not issubclass(sender, Status):
|
||||
return
|
||||
|
||||
if instance.deleted:
|
||||
Notification.objects.filter(related_status=instance).delete()
|
||||
return
|
||||
|
||||
if (
|
||||
instance.reply_parent
|
||||
and instance.reply_parent.user != instance.user
|
||||
and instance.reply_parent.user.local
|
||||
):
|
||||
Notification.objects.create(
|
||||
user=instance.reply_parent.user,
|
||||
notification_type="REPLY",
|
||||
related_user=instance.user,
|
||||
related_status=instance,
|
||||
)
|
||||
for mention_user in instance.mention_users.all():
|
||||
# avoid double-notifying about this status
|
||||
if not mention_user.local or (
|
||||
instance.reply_parent and mention_user == instance.reply_parent.user
|
||||
):
|
||||
continue
|
||||
Notification.objects.create(
|
||||
user=mention_user,
|
||||
notification_type="MENTION",
|
||||
related_user=instance.user,
|
||||
related_status=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_boost(sender, instance, *args, **kwargs):
|
||||
"""boosting a status"""
|
||||
if (
|
||||
not instance.boosted_status.user.local
|
||||
or instance.boosted_status.user == instance.user
|
||||
):
|
||||
return
|
||||
|
||||
Notification.objects.create(
|
||||
user=instance.boosted_status.user,
|
||||
related_status=instance.boosted_status,
|
||||
related_user=instance.user,
|
||||
notification_type="BOOST",
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_delete, sender=Boost)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
||||
"""unboosting a status"""
|
||||
Notification.objects.filter(
|
||||
user=instance.boosted_status.user,
|
||||
related_status=instance.boosted_status,
|
||||
related_user=instance.user,
|
||||
notification_type="BOOST",
|
||||
).delete()
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=ImportJob)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_user_on_import_complete(sender, instance, *args, **kwargs):
|
||||
"""we imported your books! aren't you proud of us"""
|
||||
if not instance.complete:
|
||||
return
|
||||
Notification.objects.create(
|
||||
user=instance.user,
|
||||
notification_type="IMPORT",
|
||||
related_import=instance,
|
||||
)
|
||||
|
||||
|
||||
@receiver(models.signals.post_save, sender=Report)
|
||||
# pylint: disable=unused-argument
|
||||
def notify_admins_on_report(sender, instance, *args, **kwargs):
|
||||
"""something is up, make sure the admins know"""
|
||||
# moderators and superusers should be notified
|
||||
admins = User.objects.filter(
|
||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| models.Q(is_superuser=True)
|
||||
).all()
|
||||
for admin in admins:
|
||||
Notification.objects.create(
|
||||
user=admin,
|
||||
related_report=instance,
|
||||
notification_type="REPORT",
|
||||
)
|
||||
|
@ -1,7 +1,7 @@
|
||||
""" progress in a book """
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.core import validators
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
@ -26,11 +26,14 @@ class ReadThrough(BookWyrmModel):
|
||||
)
|
||||
start_date = models.DateTimeField(blank=True, null=True)
|
||||
finish_date = models.DateTimeField(blank=True, null=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
self.user.update_active_date()
|
||||
# an active readthrough must have an unset finish date
|
||||
if self.finish_date:
|
||||
self.is_active = False
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def create_update(self):
|
||||
@ -41,6 +44,16 @@ class ReadThrough(BookWyrmModel):
|
||||
)
|
||||
return None
|
||||
|
||||
class Meta:
|
||||
"""Don't let readthroughs end before they start"""
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=Q(finish_date__gte=F("start_date")), name="chronology"
|
||||
)
|
||||
]
|
||||
ordering = ("-start_date",)
|
||||
|
||||
|
||||
class ProgressUpdate(BookWyrmModel):
|
||||
"""Store progress through a book in the database."""
|
||||
@ -54,6 +67,5 @@ class ProgressUpdate(BookWyrmModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""update user active time"""
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save(broadcast=False)
|
||||
self.user.update_active_date()
|
||||
super().save(*args, **kwargs)
|
||||
|
@ -53,7 +53,7 @@ class UserRelationship(BookWyrmModel):
|
||||
def get_remote_id(self):
|
||||
"""use shelf identifier in remote_id"""
|
||||
base_path = self.user_subject.remote_id
|
||||
return "%s#follows/%d" % (base_path, self.id)
|
||||
return f"{base_path}#follows/{self.id}"
|
||||
|
||||
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
@ -144,7 +144,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||
"""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)
|
||||
status_id = self.id or 0
|
||||
return f"{base_path}#{status}/{status_id}"
|
||||
|
||||
def accept(self, broadcast_only=False):
|
||||
"""turn this request into the real deal"""
|
||||
|
@ -1,5 +1,4 @@
|
||||
""" flagged for moderation """
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.db.models import F, Q
|
||||
from .base_model import BookWyrmModel
|
||||
@ -16,23 +15,6 @@ class Report(BookWyrmModel):
|
||||
statuses = models.ManyToManyField("Status", blank=True)
|
||||
resolved = models.BooleanField(default=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""notify admins when a report is created"""
|
||||
super().save(*args, **kwargs)
|
||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||
# moderators and superusers should be notified
|
||||
admins = user_model.objects.filter(
|
||||
Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||
| Q(is_superuser=True)
|
||||
).all()
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
for admin in admins:
|
||||
notification_model.objects.create(
|
||||
user=admin,
|
||||
related_report=self,
|
||||
notification_type="REPORT",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""don't let users report themselves"""
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
""" puttin' books on shelves """
|
||||
import re
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
@ -20,6 +21,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||
|
||||
name = fields.CharField(max_length=100)
|
||||
identifier = models.CharField(max_length=100)
|
||||
description = models.TextField(blank=True, null=True, max_length=500)
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="owner"
|
||||
)
|
||||
@ -44,18 +46,29 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||
def get_identifier(self):
|
||||
"""custom-shelf-123 for the url"""
|
||||
slug = re.sub(r"[^\w]", "", self.name).lower()
|
||||
return "{:s}-{:d}".format(slug, self.id)
|
||||
return f"{slug}-{self.id}"
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.order_by("shelfbook")
|
||||
|
||||
@property
|
||||
def deletable(self):
|
||||
"""can the shelf be safely deleted?"""
|
||||
return self.editable and not self.shelfbook_set.exists()
|
||||
|
||||
def get_remote_id(self):
|
||||
"""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)
|
||||
return f"{base_path}/books/{identifier}"
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""don't let anyone delete a default shelf"""
|
||||
super().raise_not_deletable(viewer)
|
||||
if not self.deletable:
|
||||
raise PermissionDenied()
|
||||
|
||||
class Meta:
|
||||
"""user/shelf unqiueness"""
|
||||
|
@ -1,8 +1,6 @@
|
||||
""" the particulars for this instance of BookWyrm """
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
from Crypto import Random
|
||||
from django.db import models, IntegrityError
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
@ -10,7 +8,7 @@ from model_utils import FieldTracker
|
||||
|
||||
from bookwyrm.preview_images import generate_site_preview_image_task
|
||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
|
||||
from .base_model import BookWyrmModel
|
||||
from .base_model import BookWyrmModel, new_access_code
|
||||
from .user import User
|
||||
|
||||
|
||||
@ -22,10 +20,17 @@ class SiteSettings(models.Model):
|
||||
max_length=150, default="Social Reading and Reviewing"
|
||||
)
|
||||
instance_description = models.TextField(default="This instance has no description.")
|
||||
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
|
||||
|
||||
# about page
|
||||
registration_closed_text = models.TextField(
|
||||
default="Contact an administrator to get an invite"
|
||||
default="We aren't taking new users at this time. You can find an open "
|
||||
'instance at <a href="https://joinbookwyrm.com/instances">'
|
||||
"joinbookwyrm.com/instances</a>."
|
||||
)
|
||||
invite_request_text = models.TextField(
|
||||
default="If your request is approved, you will receive an email with a "
|
||||
"registration link."
|
||||
)
|
||||
code_of_conduct = models.TextField(default="Add a code of conduct here.")
|
||||
privacy_policy = models.TextField(default="Add a privacy policy here.")
|
||||
@ -33,6 +38,7 @@ class SiteSettings(models.Model):
|
||||
# registration
|
||||
allow_registration = models.BooleanField(default=True)
|
||||
allow_invite_requests = models.BooleanField(default=True)
|
||||
require_confirm_email = models.BooleanField(default=True)
|
||||
|
||||
# images
|
||||
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
|
||||
@ -61,11 +67,6 @@ class SiteSettings(models.Model):
|
||||
return default_settings
|
||||
|
||||
|
||||
def new_access_code():
|
||||
"""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"""
|
||||
|
||||
@ -86,7 +87,7 @@ class SiteInvite(models.Model):
|
||||
@property
|
||||
def link(self):
|
||||
"""formats the invite link"""
|
||||
return "https://{}/invite/{}".format(DOMAIN, self.code)
|
||||
return f"https://{DOMAIN}/invite/{self.code}"
|
||||
|
||||
|
||||
class InviteRequest(BookWyrmModel):
|
||||
@ -126,7 +127,7 @@ class PasswordReset(models.Model):
|
||||
@property
|
||||
def link(self):
|
||||
"""formats the invite link"""
|
||||
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
||||
return f"https://{DOMAIN}/password-reset/{self.code}"
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
|
@ -3,8 +3,10 @@ from dataclasses import MISSING
|
||||
import re
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.dispatch import receiver
|
||||
from django.template.loader import get_template
|
||||
from django.utils import timezone
|
||||
@ -29,6 +31,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
|
||||
)
|
||||
content = fields.HtmlField(blank=True, null=True)
|
||||
raw_content = models.TextField(blank=True, null=True)
|
||||
mention_users = fields.TagField("User", related_name="mention_user")
|
||||
mention_books = fields.TagField("Edition", related_name="mention_book")
|
||||
local = models.BooleanField(default=True)
|
||||
@ -41,6 +44,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
published_date = fields.DateTimeField(
|
||||
default=timezone.now, activitypub_field="published"
|
||||
)
|
||||
edited_date = fields.DateTimeField(
|
||||
blank=True, null=True, activitypub_field="updated"
|
||||
)
|
||||
deleted = models.BooleanField(default=False)
|
||||
deleted_date = models.DateTimeField(blank=True, null=True)
|
||||
favorites = models.ManyToManyField(
|
||||
@ -56,6 +62,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
on_delete=models.PROTECT,
|
||||
activitypub_field="inReplyTo",
|
||||
)
|
||||
thread_id = models.IntegerField(blank=True, null=True)
|
||||
objects = InheritanceManager()
|
||||
|
||||
activity_serializer = activitypub.Note
|
||||
@ -69,37 +76,14 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save and notify"""
|
||||
if self.reply_parent:
|
||||
self.thread_id = self.reply_parent.thread_id or self.reply_parent.id
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
|
||||
if self.deleted:
|
||||
notification_model.objects.filter(related_status=self).delete()
|
||||
return
|
||||
|
||||
if (
|
||||
self.reply_parent
|
||||
and self.reply_parent.user != self.user
|
||||
and self.reply_parent.user.local
|
||||
):
|
||||
notification_model.objects.create(
|
||||
user=self.reply_parent.user,
|
||||
notification_type="REPLY",
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
for mention_user in self.mention_users.all():
|
||||
# avoid double-notifying about this status
|
||||
if not mention_user.local or (
|
||||
self.reply_parent and mention_user == self.reply_parent.user
|
||||
):
|
||||
continue
|
||||
notification_model.objects.create(
|
||||
user=mention_user,
|
||||
notification_type="MENTION",
|
||||
related_user=self.user,
|
||||
related_status=self,
|
||||
)
|
||||
if not self.reply_parent:
|
||||
self.thread_id = self.id
|
||||
super().save(broadcast=False, update_fields=["thread_id"])
|
||||
|
||||
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
|
||||
""" "delete" a status"""
|
||||
@ -108,6 +92,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
super().delete(*args, **kwargs)
|
||||
return
|
||||
self.deleted = True
|
||||
# clear user content
|
||||
self.content = None
|
||||
if hasattr(self, "quotation"):
|
||||
self.quotation = None # pylint: disable=attribute-defined-outside-init
|
||||
self.deleted_date = timezone.now()
|
||||
self.save()
|
||||
|
||||
@ -179,9 +167,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
"""helper function for loading AP serialized replies to a status"""
|
||||
return self.to_ordered_collection(
|
||||
self.replies(self),
|
||||
remote_id="%s/replies" % self.remote_id,
|
||||
remote_id=f"{self.remote_id}/replies",
|
||||
collection_only=True,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
).serialize()
|
||||
|
||||
def to_activity_dataclass(self, pure=False): # pylint: disable=arguments-differ
|
||||
@ -217,6 +205,35 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
"""json serialized activitypub class"""
|
||||
return self.to_activity_dataclass(pure=pure).serialize()
|
||||
|
||||
def raise_not_editable(self, viewer):
|
||||
"""certain types of status aren't editable"""
|
||||
# first, the standard raise
|
||||
super().raise_not_editable(viewer)
|
||||
if isinstance(self, (GeneratedNote, ReviewRating)):
|
||||
raise PermissionDenied()
|
||||
|
||||
@classmethod
|
||||
def privacy_filter(cls, viewer, privacy_levels=None):
|
||||
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
|
||||
return queryset.filter(deleted=False)
|
||||
|
||||
@classmethod
|
||||
def direct_filter(cls, queryset, viewer):
|
||||
"""Overridden filter for "direct" privacy level"""
|
||||
return queryset.exclude(
|
||||
~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def followers_filter(cls, queryset, viewer):
|
||||
"""Override-able filter for "followers" privacy level"""
|
||||
return queryset.exclude(
|
||||
~Q( # not yourself, a follower, or someone who is tagged
|
||||
Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
|
||||
),
|
||||
privacy="followers", # and the status is followers only
|
||||
)
|
||||
|
||||
|
||||
class GeneratedNote(Status):
|
||||
"""these are app-generated messages about user activity"""
|
||||
@ -226,21 +243,40 @@ class GeneratedNote(Status):
|
||||
"""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)
|
||||
f'<a href="{book.remote_id}">"{book.title}"</a>'
|
||||
for book in self.mention_books.all()
|
||||
)
|
||||
return "%s %s %s" % (self.user.display_name, message, books)
|
||||
return f"{self.user.display_name} {message} {books}"
|
||||
|
||||
activity_serializer = activitypub.GeneratedNote
|
||||
pure_type = "Note"
|
||||
|
||||
|
||||
class Comment(Status):
|
||||
"""like a review but without a rating and transient"""
|
||||
ReadingStatusChoices = models.TextChoices(
|
||||
"ReadingStatusChoices", ["to-read", "reading", "read"]
|
||||
)
|
||||
|
||||
|
||||
class BookStatus(Status):
|
||||
"""Shared fields for comments, quotes, reviews"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||
)
|
||||
pure_type = "Note"
|
||||
|
||||
reading_status = fields.CharField(
|
||||
max_length=255, choices=ReadingStatusChoices.choices, null=True, blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""not a real model, sorry"""
|
||||
|
||||
abstract = True
|
||||
|
||||
|
||||
class Comment(BookStatus):
|
||||
"""like a review but without a rating and transient"""
|
||||
|
||||
# this is it's own field instead of a foreign key to the progress update
|
||||
# so that the update can be deleted without impacting the status
|
||||
@ -258,22 +294,28 @@ class Comment(Status):
|
||||
@property
|
||||
def pure_content(self):
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % (
|
||||
self.content,
|
||||
self.book.remote_id,
|
||||
self.book.title,
|
||||
return (
|
||||
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a>)</p>'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Comment
|
||||
pure_type = "Note"
|
||||
|
||||
|
||||
class Quotation(Status):
|
||||
class Quotation(BookStatus):
|
||||
"""like a review but without a rating and transient"""
|
||||
|
||||
quote = fields.HtmlField()
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||
raw_quote = models.TextField(blank=True, null=True)
|
||||
position = models.IntegerField(
|
||||
validators=[MinValueValidator(0)], null=True, blank=True
|
||||
)
|
||||
position_mode = models.CharField(
|
||||
max_length=3,
|
||||
choices=ProgressMode.choices,
|
||||
default=ProgressMode.PAGE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@property
|
||||
@ -281,24 +323,18 @@ class Quotation(Status):
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
quote = re.sub(r"^<p>", '<p>"', self.quote)
|
||||
quote = re.sub(r"</p>$", '"</p>', quote)
|
||||
return '%s <p>-- <a href="%s">"%s"</a></p>%s' % (
|
||||
quote,
|
||||
self.book.remote_id,
|
||||
self.book.title,
|
||||
self.content,
|
||||
return (
|
||||
f'{quote} <p>-- <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a></p>{self.content}'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Quotation
|
||||
pure_type = "Note"
|
||||
|
||||
|
||||
class Review(Status):
|
||||
class Review(BookStatus):
|
||||
"""a book review"""
|
||||
|
||||
name = fields.CharField(max_length=255, null=True)
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook"
|
||||
)
|
||||
rating = fields.DecimalField(
|
||||
default=None,
|
||||
null=True,
|
||||
@ -368,27 +404,6 @@ class Boost(ActivityMixin, Status):
|
||||
return
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
|
||||
return
|
||||
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_model.objects.create(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type="BOOST",
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""delete and un-notify"""
|
||||
notification_model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||
notification_model.objects.filter(
|
||||
user=self.boosted_status.user,
|
||||
related_status=self.boosted_status,
|
||||
related_user=self.user,
|
||||
notification_type="BOOST",
|
||||
).delete()
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""the user field is "actor" here instead of "attributedTo" """
|
||||
@ -401,10 +416,6 @@ class Boost(ActivityMixin, Status):
|
||||
self.image_fields = []
|
||||
self.deserialize_reverse_fields = []
|
||||
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@receiver(models.signals.post_save)
|
||||
|
@ -7,7 +7,7 @@ from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.contrib.postgres.fields import CICharField
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.dispatch import receiver
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from model_utils import FieldTracker
|
||||
import pytz
|
||||
@ -17,16 +17,22 @@ from bookwyrm.connectors import get_data, ConnectorException
|
||||
from bookwyrm.models.shelf import Shelf
|
||||
from bookwyrm.models.status import Status, Review
|
||||
from bookwyrm.preview_images import generate_user_preview_image_task
|
||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
|
||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
|
||||
from bookwyrm.signatures import create_key_pair
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.utils import regex
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
||||
from .base_model import BookWyrmModel, DeactivationReason
|
||||
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
||||
from .federated_server import FederatedServer
|
||||
from . import fields, Review
|
||||
|
||||
|
||||
def site_link():
|
||||
"""helper for generating links to the site"""
|
||||
protocol = "https" if USE_HTTPS else "http"
|
||||
return f"{protocol}://{DOMAIN}"
|
||||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
"""a user who wants to read books"""
|
||||
|
||||
@ -76,9 +82,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
preview_image = models.ImageField(
|
||||
upload_to="previews/avatars/", blank=True, null=True
|
||||
)
|
||||
followers = fields.ManyToManyField(
|
||||
followers_url = fields.CharField(max_length=255, activitypub_field="followers")
|
||||
followers = models.ManyToManyField(
|
||||
"self",
|
||||
link_only=True,
|
||||
symmetrical=False,
|
||||
through="UserFollows",
|
||||
through_fields=("user_object", "user_subject"),
|
||||
@ -98,6 +104,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
through_fields=("user_subject", "user_object"),
|
||||
related_name="blocked_by",
|
||||
)
|
||||
saved_lists = models.ManyToManyField(
|
||||
"List", symmetrical=False, related_name="saved_lists", blank=True
|
||||
)
|
||||
favorites = models.ManyToManyField(
|
||||
"Status",
|
||||
symmetrical=False,
|
||||
@ -105,35 +114,57 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
through_fields=("user", "status"),
|
||||
related_name="favorite_statuses",
|
||||
)
|
||||
default_post_privacy = models.CharField(
|
||||
max_length=255, default="public", choices=fields.PrivacyLevels.choices
|
||||
)
|
||||
remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id")
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
last_active_date = models.DateTimeField(auto_now=True)
|
||||
last_active_date = models.DateTimeField(default=timezone.now)
|
||||
manually_approves_followers = fields.BooleanField(default=False)
|
||||
|
||||
# options to turn features on and off
|
||||
show_goal = models.BooleanField(default=True)
|
||||
show_suggested_users = models.BooleanField(default=True)
|
||||
discoverable = fields.BooleanField(default=False)
|
||||
|
||||
preferred_timezone = models.CharField(
|
||||
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
|
||||
default=str(pytz.utc),
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
preferred_language = models.CharField(
|
||||
choices=LANGUAGES,
|
||||
null=True,
|
||||
blank=True,
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||
)
|
||||
deactivation_date = models.DateTimeField(null=True, blank=True)
|
||||
confirmation_code = models.CharField(max_length=32, default=new_access_code)
|
||||
|
||||
name_field = "username"
|
||||
property_fields = [("following_link", "following")]
|
||||
field_tracker = FieldTracker(fields=["name", "avatar"])
|
||||
|
||||
@property
|
||||
def confirmation_link(self):
|
||||
"""helper for generating confirmation links"""
|
||||
link = site_link()
|
||||
return f"{link}/confirm-email/{self.confirmation_code}"
|
||||
|
||||
@property
|
||||
def following_link(self):
|
||||
"""just how to find out the following info"""
|
||||
return "{:s}/following".format(self.remote_id)
|
||||
return f"{self.remote_id}/following"
|
||||
|
||||
@property
|
||||
def alt_text(self):
|
||||
"""alt text with username"""
|
||||
return "avatar for %s" % (self.localname or self.username)
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "avatar for {:s}".format(self.localname or self.username)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
@ -170,12 +201,15 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
def update_active_date(self):
|
||||
"""this user is here! they are doing things!"""
|
||||
self.last_active_date = timezone.now()
|
||||
self.save(broadcast=False, update_fields=["last_active_date"])
|
||||
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
"""an ordered collection of statuses"""
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
"bookwyrm.%s" % filter_type, require_ready=True
|
||||
)
|
||||
filter_class = apps.get_model(f"bookwyrm.{filter_type}", require_ready=True)
|
||||
if not issubclass(filter_class, Status):
|
||||
raise TypeError(
|
||||
"filter_status_class must be a subclass of models.Status"
|
||||
@ -199,22 +233,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
|
||||
def to_following_activity(self, **kwargs):
|
||||
"""activitypub following list"""
|
||||
remote_id = "%s/following" % self.remote_id
|
||||
remote_id = f"{self.remote_id}/following"
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def to_followers_activity(self, **kwargs):
|
||||
"""activitypub followers list"""
|
||||
remote_id = "%s/followers" % self.remote_id
|
||||
remote_id = self.followers_url
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by("-updated_date").all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def to_activity(self, **kwargs):
|
||||
@ -242,42 +276,65 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
if not self.local and not re.match(regex.FULL_USERNAME, self.username):
|
||||
# generate a username that uses the domain (webfinger format)
|
||||
actor_parts = urlparse(self.remote_id)
|
||||
self.username = "%s@%s" % (self.username, actor_parts.netloc)
|
||||
super().save(*args, **kwargs)
|
||||
self.username = f"{self.username}@{actor_parts.netloc}"
|
||||
|
||||
# this user already exists, no need to populate fields
|
||||
if not created:
|
||||
if self.is_active:
|
||||
self.deactivation_date = None
|
||||
elif not self.deactivation_date:
|
||||
self.deactivation_date = timezone.now()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
return
|
||||
|
||||
# this is a new remote user, we need to set their remote server field
|
||||
if not self.local:
|
||||
super().save(*args, **kwargs)
|
||||
set_remote_server.delay(self.id)
|
||||
transaction.on_commit(lambda: set_remote_server.delay(self.id))
|
||||
return
|
||||
|
||||
# populate fields for local users
|
||||
self.remote_id = "https://%s/user/%s" % (DOMAIN, self.localname)
|
||||
self.inbox = "%s/inbox" % self.remote_id
|
||||
self.shared_inbox = "https://%s/inbox" % DOMAIN
|
||||
self.outbox = "%s/outbox" % self.remote_id
|
||||
with transaction.atomic():
|
||||
# populate fields for local users
|
||||
link = site_link()
|
||||
self.remote_id = f"{link}/user/{self.localname}"
|
||||
self.followers_url = f"{self.remote_id}/followers"
|
||||
self.inbox = f"{self.remote_id}/inbox"
|
||||
self.shared_inbox = f"{link}/inbox"
|
||||
self.outbox = f"{self.remote_id}/outbox"
|
||||
|
||||
# an id needs to be set before we can proceed with related models
|
||||
# an id needs to be set before we can proceed with related models
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# make users editors by default
|
||||
try:
|
||||
self.groups.add(Group.objects.get(name="editor"))
|
||||
except Group.DoesNotExist:
|
||||
# this should only happen in tests
|
||||
pass
|
||||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id=f"{self.remote_id}/#main-key"
|
||||
)
|
||||
self.save(broadcast=False, update_fields=["key_pair"])
|
||||
|
||||
self.create_shelves()
|
||||
|
||||
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)
|
||||
|
||||
# make users editors by default
|
||||
try:
|
||||
self.groups.add(Group.objects.get(name="editor"))
|
||||
except Group.DoesNotExist:
|
||||
# this should only happen in tests
|
||||
pass
|
||||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id="%s/#main-key" % self.remote_id
|
||||
)
|
||||
self.save(broadcast=False)
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "/user/{:s}".format(self.localname or self.username)
|
||||
|
||||
def create_shelves(self):
|
||||
"""default shelves for a new user"""
|
||||
shelves = [
|
||||
{
|
||||
"name": "To Read",
|
||||
@ -301,17 +358,6 @@ 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"""
|
||||
return "/user/%s" % (self.localname or self.username)
|
||||
|
||||
|
||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
"""public and private keys for a user"""
|
||||
@ -326,7 +372,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
|
||||
def get_remote_id(self):
|
||||
# self.owner is set by the OneToOneField on User
|
||||
return "%s/#main-key" % self.owner.remote_id
|
||||
return f"{self.owner.remote_id}/#main-key"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""create a key pair"""
|
||||
@ -363,7 +409,7 @@ class AnnualGoal(BookWyrmModel):
|
||||
|
||||
def get_remote_id(self):
|
||||
"""put the year in the path"""
|
||||
return "{:s}/goal/{:d}".format(self.user.remote_id, self.year)
|
||||
return f"{self.user.remote_id}/goal/{self.year}"
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
@ -400,13 +446,13 @@ class AnnualGoal(BookWyrmModel):
|
||||
}
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def set_remote_server(user_id):
|
||||
"""figure out the user's remote server in the background"""
|
||||
user = User.objects.get(id=user_id)
|
||||
actor_parts = urlparse(user.remote_id)
|
||||
user.federated_server = get_or_create_remote_server(actor_parts.netloc)
|
||||
user.save(broadcast=False)
|
||||
user.save(broadcast=False, update_fields=["federated_server"])
|
||||
if user.bookwyrm_user and user.outbox:
|
||||
get_remote_reviews.delay(user.outbox)
|
||||
|
||||
@ -419,7 +465,7 @@ def get_or_create_remote_server(domain):
|
||||
pass
|
||||
|
||||
try:
|
||||
data = get_data("https://%s/.well-known/nodeinfo" % domain)
|
||||
data = get_data(f"https://{domain}/.well-known/nodeinfo")
|
||||
try:
|
||||
nodeinfo_url = data.get("links")[0].get("href")
|
||||
except (TypeError, KeyError):
|
||||
@ -439,7 +485,7 @@ def get_or_create_remote_server(domain):
|
||||
return server
|
||||
|
||||
|
||||
@app.task
|
||||
@app.task(queue="low_priority")
|
||||
def get_remote_reviews(outbox):
|
||||
"""ingest reviews by a new remote bookwyrm user"""
|
||||
outbox_page = outbox + "?page=true&type=Review"
|
||||
|
Reference in New Issue
Block a user