Merge branch 'main' into csv-import-failures
This commit is contained in:
@ -24,7 +24,9 @@ from .federated_server import FederatedServer
|
||||
|
||||
from .import_job import ImportJob, ImportItem
|
||||
|
||||
from .site import SiteSettings, SiteInvite, PasswordReset, InviteRequest
|
||||
from .site import SiteSettings, SiteInvite
|
||||
from .site import PasswordReset, InviteRequest
|
||||
from .site import EmailBlocklist
|
||||
from .announcement import Announcement
|
||||
|
||||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
|
@ -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"""
|
||||
@ -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)
|
||||
|
@ -13,6 +13,7 @@ DeactivationReason = models.TextChoices(
|
||||
[
|
||||
"pending",
|
||||
"self_deletion",
|
||||
"moderator_suspension",
|
||||
"moderator_deletion",
|
||||
"domain_block",
|
||||
],
|
||||
|
@ -4,13 +4,20 @@ 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 transaction
|
||||
from django.dispatch import receiver
|
||||
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 +104,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"""
|
||||
@ -321,4 +362,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)
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -66,7 +66,7 @@ class ActivitypubFieldMixin:
|
||||
self.activitypub_field = activitypub_field
|
||||
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. Returns if changed"""
|
||||
try:
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
@ -79,8 +79,15 @@ class ActivitypubFieldMixin:
|
||||
if formatted is None or formatted is MISSING or formatted == {}:
|
||||
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 hasattr(instance, self.name) and getattr(instance, self.name) == formatted:
|
||||
if current_value == formatted:
|
||||
return False
|
||||
|
||||
setattr(instance, self.name, formatted)
|
||||
@ -210,12 +217,27 @@ 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:
|
||||
@ -231,9 +253,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
|
||||
@ -273,8 +293,11 @@ 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:
|
||||
@ -377,13 +400,16 @@ 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 False
|
||||
|
||||
if not overwrite and hasattr(instance, self.name):
|
||||
return False
|
||||
|
||||
getattr(instance, self.name).save(*formatted, save=save)
|
||||
return True
|
||||
|
||||
|
@ -188,6 +188,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,
|
||||
|
@ -1,7 +1,8 @@
|
||||
""" 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 django.utils import timezone
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
@ -41,6 +42,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."""
|
||||
|
@ -20,6 +20,7 @@ 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(
|
||||
@ -123,6 +124,23 @@ class PasswordReset(models.Model):
|
||||
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
|
||||
|
||||
|
||||
class EmailBlocklist(models.Model):
|
||||
"""blocked email addresses"""
|
||||
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
domain = models.CharField(max_length=255, unique=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}")
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@receiver(models.signals.post_save, sender=SiteSettings)
|
||||
def preview_image(instance, *args, **kwargs):
|
||||
|
@ -235,12 +235,31 @@ class GeneratedNote(Status):
|
||||
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
|
||||
@ -265,15 +284,21 @@ class Comment(Status):
|
||||
)
|
||||
|
||||
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"
|
||||
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
|
||||
@ -289,16 +314,12 @@ class Quotation(Status):
|
||||
)
|
||||
|
||||
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,
|
||||
|
@ -82,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"),
|
||||
@ -104,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"
|
||||
)
|
||||
favorites = models.ManyToManyField(
|
||||
"Status",
|
||||
symmetrical=False,
|
||||
@ -119,8 +122,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
updated_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),
|
||||
@ -225,7 +232,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
|
||||
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,
|
||||
@ -271,28 +278,46 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
transaction.on_commit(lambda: set_remote_server.delay(self.id))
|
||||
return
|
||||
|
||||
# populate fields for local users
|
||||
self.remote_id = "%s/user/%s" % (site_link(), self.localname)
|
||||
self.inbox = "%s/inbox" % self.remote_id
|
||||
self.shared_inbox = "%s/inbox" % site_link()
|
||||
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, update_fields=["key_pair"])
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
return "/user/%s" % (self.localname or self.username)
|
||||
|
||||
def create_shelves(self):
|
||||
"""default shelves for a new user"""
|
||||
shelves = [
|
||||
{
|
||||
"name": "To Read",
|
||||
@ -316,17 +341,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"""
|
||||
@ -415,7 +429,7 @@ 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)
|
||||
@ -454,7 +468,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