1
0
Fork 0

Merge branch 'main' into misc/add_signatures_to_requests_for_masto_compat

This commit is contained in:
Mouse Reeve 2022-07-26 15:46:18 -07:00 committed by GitHub
commit 7eb2a82e5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
396 changed files with 40203 additions and 12538 deletions

View file

@ -26,7 +26,7 @@ from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite
from .site import SiteSettings, Theme, SiteInvite
from .site import PasswordReset, InviteRequest
from .announcement import Announcement
from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task

View file

@ -8,7 +8,6 @@ from .base_model import BookWyrmModel
DisplayTypes = [
("white-ter", _("None")),
("primary-light", _("Primary")),
("success-light", _("Success")),
("link-light", _("Link")),
@ -28,11 +27,7 @@ class Announcement(BookWyrmModel):
end_date = models.DateTimeField(blank=True, null=True)
active = models.BooleanField(default=True)
display_type = models.CharField(
max_length=20,
blank=False,
null=False,
choices=DisplayTypes,
default="white-ter",
max_length=20, choices=DisplayTypes, null=True, blank=True
)
@classmethod

View file

@ -3,7 +3,7 @@ from functools import reduce
import operator
from django.apps import apps
from django.db import models
from django.db import models, transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
@ -58,25 +58,20 @@ def automod_task():
return
reporter = AutoMod.objects.first().created_by
reports = automod_users(reporter) + automod_statuses(reporter)
if reports:
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
notification_model = apps.get_model(
"bookwyrm", "Notification", require_ready=True
)
if not reports:
return
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
with transaction.atomic():
for admin in admins:
notification_model.objects.bulk_create(
[
notification_model(
user=admin,
related_report=r,
notification_type="REPORT",
)
for r in reports
]
notification, _ = notification_model.objects.get_or_create(
user=admin, notification_type=notification_model.REPORT, read=False
)
notification.related_repors.add(reports)
def automod_users(reporter):

View file

@ -8,6 +8,7 @@ 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 django.utils.text import slugify
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
@ -35,10 +36,11 @@ class BookWyrmModel(models.Model):
remote_id = RemoteIdField(null=True, activitypub_field="id")
def get_remote_id(self):
"""generate a url that resolves to the local object"""
"""generate the url that resolves to the local object, without a slug"""
base_path = f"https://{DOMAIN}"
if hasattr(self, "user"):
base_path = f"{base_path}{self.user.local_path}"
model_name = type(self).__name__.lower()
return f"{base_path}/{model_name}/{self.id}"
@ -49,8 +51,20 @@ 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(f"https://{DOMAIN}", "")
"""how to link to this object in the local app, with a slug"""
local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
name = None
if hasattr(self, "name_field"):
name = getattr(self, self.name_field)
elif hasattr(self, "name"):
name = self.name
if name:
slug = slugify(name)
local = f"{local}/s/{slug}"
return local
def raise_visible_to_user(self, viewer):
"""is a user authorized to view an object?"""
@ -118,7 +132,7 @@ class BookWyrmModel(models.Model):
return
# but generally moderators can delete other people's stuff
if self.user == viewer or viewer.has_perm("moderate_post"):
if self.user == viewer or viewer.has_perm("bookwyrm.moderate_post"):
return
raise PermissionDenied()

View file

@ -176,8 +176,8 @@ class Book(BookDataModel):
"""properties of this edition, as a string"""
items = [
self.physical_format if hasattr(self, "physical_format") else None,
self.languages[0] + " language"
if self.languages and self.languages[0] != "English"
f"{self.languages[0]} language"
if self.languages and self.languages[0] and self.languages[0] != "English"
else None,
str(self.published_date.year) if self.published_date else None,
", ".join(self.publishers) if hasattr(self, "publishers") else None,

View file

@ -16,7 +16,7 @@ from django.utils.encoding import filepath_to_uri
from bookwyrm import activitypub
from bookwyrm.connectors import get_image
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.utils.sanitizer import clean
from bookwyrm.settings import MEDIA_FULL_URL
@ -125,7 +125,7 @@ class ActivitypubFieldMixin:
"""model_field_name to activitypubFieldName"""
if self.activitypub_field:
return self.activitypub_field
name = self.name.split(".")[-1]
name = self.name.rsplit(".", maxsplit=1)[-1]
components = name.split("_")
return components[0] + "".join(x.title() for x in components[1:])
@ -389,7 +389,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ
# pylint: disable=arguments-differ,arguments-renamed
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())
@ -497,9 +497,7 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
def field_from_activity(self, value):
if not value or value == MISSING:
return None
sanitizer = InputHtmlParser()
sanitizer.feed(value)
return sanitizer.get_output()
return clean(value)
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):

View file

@ -140,16 +140,6 @@ class GroupMemberInvitation(models.Model):
# 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,
)
@transaction.atomic
def accept(self):
"""turn this request into the real deal"""
@ -157,25 +147,24 @@ class GroupMemberInvitation(models.Model):
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
model.objects.create(
user=self.group.user,
related_user=self.user,
model.notify(
self.group.user,
self.user,
related_group=self.group,
notification_type="ACCEPT",
notification_type=model.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,
model.notify(
member,
self.user,
related_group=self.group,
notification_type="JOIN",
notification_type=model.JOIN,
)
def reject(self):
"""generate a Reject for this membership request"""
self.delete()

View file

@ -175,9 +175,15 @@ class ImportItem(models.Model):
def date_added(self):
"""when the book was added to this dataset"""
if self.normalized_data.get("date_added"):
return timezone.make_aware(
dateutil.parser.parse(self.normalized_data.get("date_added"))
parsed_date_added = dateutil.parser.parse(
self.normalized_data.get("date_added")
)
if timezone.is_aware(parsed_date_added):
# Keep timezone if import already had one
return parsed_date_added
return timezone.make_aware(parsed_date_added)
return None
@property

View file

@ -84,7 +84,7 @@ class LinkDomain(BookWyrmModel):
)
def raise_not_editable(self, viewer):
if viewer.has_perm("moderate_post"):
if viewer.has_perm("bookwyrm.moderate_post"):
return
raise PermissionDenied()

View file

@ -1,7 +1,6 @@
""" make a list of books!! """
import uuid
from django.apps import apps
from django.core.exceptions import PermissionDenied
from django.db import models
from django.db.models import Q
@ -129,7 +128,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
"""on save, update embed_key and avoid clash with existing code"""
if not self.embed_key:
self.embed_key = uuid.uuid4()
return super().save(*args, **kwargs)
super().save(*args, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel):
@ -151,33 +150,11 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
collection_field = "book_list"
def save(self, *args, **kwargs):
"""create a notification too"""
created = not bool(self.id)
"""Update the list's date"""
super().save(*args, **kwargs)
# tick the updated date on the parent list
self.book_list.updated_date = timezone.now()
self.book_list.save(broadcast=False)
list_owner = self.book_list.user
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.objects.create(
user=list_owner,
related_user=self.user,
related_list_item=self,
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",
)
self.book_list.save(broadcast=False, update_fields=["updated_date"])
def raise_not_deletable(self, viewer):
"""the associated user OR the list owner can delete"""

View file

@ -1,77 +1,125 @@
""" alert a user to activity """
from django.db import models
from django.db import models, transaction
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 INVITE ACCEPT JOIN LEAVE REMOVE GROUP_PRIVACY GROUP_NAME GROUP_DESCRIPTION",
)
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report
from . import Status, User, UserFollowRequest
class Notification(BookWyrmModel):
"""you've been tagged, liked, followed, etc"""
# Status interactions
FAVORITE = "FAVORITE"
BOOST = "BOOST"
REPLY = "REPLY"
MENTION = "MENTION"
TAG = "TAG"
# Relationships
FOLLOW = "FOLLOW"
FOLLOW_REQUEST = "FOLLOW_REQUEST"
# Imports
IMPORT = "IMPORT"
# List activity
ADD = "ADD"
# Admin
REPORT = "REPORT"
# Groups
INVITE = "INVITE"
ACCEPT = "ACCEPT"
JOIN = "JOIN"
LEAVE = "LEAVE"
REMOVE = "REMOVE"
GROUP_PRIVACY = "GROUP_PRIVACY"
GROUP_NAME = "GROUP_NAME"
GROUP_DESCRIPTION = "GROUP_DESCRIPTION"
# pylint: disable=line-too-long
NotificationType = models.TextChoices(
# there has got be a better way to do this
"NotificationType",
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
)
user = models.ForeignKey("User", on_delete=models.CASCADE)
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
related_user = models.ForeignKey(
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices
)
related_users = models.ManyToManyField(
"User", symmetrical=False, related_name="notifications"
)
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(
"ListItem", on_delete=models.CASCADE, null=True
)
related_report = models.ForeignKey("Report", on_delete=models.CASCADE, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices
related_list_items = models.ManyToManyField(
"ListItem", symmetrical=False, related_name="notifications"
)
related_reports = models.ManyToManyField("Report", symmetrical=False)
def save(self, *args, **kwargs):
"""save, but don't make dupes"""
# there's probably a better way to do this
if self.__class__.objects.filter(
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
related_group=self.related_group,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,
related_report=self.related_report,
notification_type=self.notification_type,
).exists():
@classmethod
@transaction.atomic
def notify(cls, user, related_user, **kwargs):
"""Create a notification"""
if related_user and (not user.local or user == related_user):
return
super().save(*args, **kwargs)
notification = cls.objects.filter(user=user, **kwargs).first()
if not notification:
notification = cls.objects.create(user=user, **kwargs)
if related_user:
notification.related_users.add(related_user)
notification.read = False
notification.save()
class Meta:
"""checks if notifcation is in enum list for valid types"""
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
@classmethod
@transaction.atomic
def notify_list_item(cls, user, list_item):
"""Group the notifications around the list items, not the user"""
related_user = list_item.user
notification = cls.objects.filter(
user=user,
related_users=related_user,
related_list_items__book_list=list_item.book_list,
notification_type=Notification.ADD,
).first()
if not notification:
notification = cls.objects.create(
user=user, notification_type=Notification.ADD
)
]
notification.related_users.add(related_user)
notification.related_list_items.add(list_item)
notification.read = False
notification.save()
@classmethod
def unnotify(cls, user, related_user, **kwargs):
"""Remove a user from a notification and delete it if that was the only user"""
try:
notification = cls.objects.filter(user=user, **kwargs).get()
except Notification.DoesNotExist:
return
notification.related_users.remove(related_user)
if not notification.related_users.count():
notification.delete()
@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,
Notification.notify(
instance.status.user,
instance.user,
related_status=instance.status,
notification_type=Notification.FAVORITE,
)
@ -81,15 +129,16 @@ 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,
Notification.unnotify(
instance.status.user,
instance.user,
related_status=instance.status,
notification_type="FAVORITE",
).delete()
notification_type=Notification.FAVORITE,
)
@receiver(models.signals.post_save)
@transaction.atomic
# pylint: disable=unused-argument
def notify_user_on_mention(sender, instance, *args, **kwargs):
"""creating and deleting statuses with @ mentions and replies"""
@ -105,22 +154,23 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
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,
Notification.notify(
instance.reply_parent.user,
instance.user,
related_status=instance,
notification_type=Notification.REPLY,
)
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,
Notification.notify(
mention_user,
instance.user,
notification_type=Notification.MENTION,
related_status=instance,
)
@ -135,11 +185,11 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
):
return
Notification.objects.create(
user=instance.boosted_status.user,
Notification.notify(
instance.boosted_status.user,
instance.user,
related_status=instance.boosted_status,
related_user=instance.user,
notification_type="BOOST",
notification_type=Notification.BOOST,
)
@ -147,12 +197,12 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
# pylint: disable=unused-argument
def notify_user_on_unboost(sender, instance, *args, **kwargs):
"""unboosting a status"""
Notification.objects.filter(
user=instance.boosted_status.user,
Notification.unnotify(
instance.boosted_status.user,
instance.user,
related_status=instance.boosted_status,
related_user=instance.user,
notification_type="BOOST",
).delete()
notification_type=Notification.BOOST,
)
@receiver(models.signals.post_save, sender=ImportJob)
@ -166,23 +216,94 @@ def notify_user_on_import_complete(
return
Notification.objects.create(
user=instance.user,
notification_type="IMPORT",
notification_type=Notification.IMPORT,
related_import=instance,
)
@receiver(models.signals.post_save, sender=Report)
@transaction.atomic
# pylint: disable=unused-argument
def notify_admins_on_report(sender, instance, *args, **kwargs):
def notify_admins_on_report(sender, instance, created, *args, **kwargs):
"""something is up, make sure the admins know"""
if not created:
# otherwise you'll get a notification when you resolve a report
return
# 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(
notification, _ = Notification.objects.get_or_create(
user=admin,
related_report=instance,
notification_type="REPORT",
notification_type=Notification.REPORT,
read=False,
)
notification.related_reports.add(instance)
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
# pylint: disable=unused-argument
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
"""Cool kids club here we come"""
Notification.notify(
instance.user,
instance.group.user,
related_group=instance.group,
notification_type=Notification.INVITE,
)
@receiver(models.signals.post_save, sender=ListItem)
@transaction.atomic
# pylint: disable=unused-argument
def notify_user_on_list_item_add(sender, instance, created, *args, **kwargs):
"""Someone added to your list"""
if not created:
return
list_owner = instance.book_list.user
# create a notification if somoene ELSE added to a local user's list
if list_owner.local and list_owner != instance.user:
# keep the related_user singular, group the items
Notification.notify_list_item(list_owner, instance)
if instance.book_list.group:
for membership in instance.book_list.group.memberships.all():
if membership.user != instance.user:
Notification.notify_list_item(membership.user, instance)
@receiver(models.signals.post_save, sender=UserFollowRequest)
@transaction.atomic
# pylint: disable=unused-argument
def notify_user_on_follow(sender, instance, created, *args, **kwargs):
"""Someone added to your list"""
if not created or not instance.user_object.local:
return
manually_approves = instance.user_object.manually_approves_followers
if manually_approves:
# don't group notifications
notification = Notification.objects.filter(
user=instance.user_object,
related_users=instance.user_subject,
notification_type=Notification.FOLLOW_REQUEST,
).first()
if not notification:
notification = Notification.objects.create(
user=instance.user_object, notification_type=Notification.FOLLOW_REQUEST
)
notification.related_users.set([instance.user_subject])
notification.read = False
notification.save()
else:
# Only group unread follows
Notification.notify(
instance.user_object,
instance.user_subject,
notification_type=Notification.FOLLOW,
read=False,
)

View file

@ -27,6 +27,7 @@ class ReadThrough(BookWyrmModel):
)
start_date = models.DateTimeField(blank=True, null=True)
finish_date = models.DateTimeField(blank=True, null=True)
stopped_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
@ -34,7 +35,7 @@ class ReadThrough(BookWyrmModel):
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
self.user.update_active_date()
# an active readthrough must have an unset finish date
if self.finish_date:
if self.finish_date or self.stopped_date:
self.is_active = False
super().save(*args, **kwargs)

View file

@ -1,5 +1,4 @@
""" defines relationships between users """
from django.apps import apps
from django.core.cache import cache
from django.db import models, transaction, IntegrityError
from django.db.models import Q
@ -39,15 +38,14 @@ class UserRelationship(BookWyrmModel):
def save(self, *args, **kwargs):
"""clear the template cache"""
# invalidate the template cache
cache.delete_many(
[
f"relationship-{self.user_subject.id}-{self.user_object.id}",
f"relationship-{self.user_object.id}-{self.user_subject.id}",
]
)
clear_cache(self.user_subject, self.user_object)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""clear the template cache"""
clear_cache(self.user_subject, self.user_object)
super().delete(*args, **kwargs)
class Meta:
"""relationships should be unique"""
@ -90,7 +88,9 @@ class UserFollows(ActivityMixin, UserRelationship):
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
raise IntegrityError(
"Attempting to follow blocked user", self.user_subject, self.user_object
)
# don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs)
@ -98,11 +98,12 @@ class UserFollows(ActivityMixin, UserRelationship):
@classmethod
def from_request(cls, follow_request):
"""converts a follow request into a follow relationship"""
return cls.objects.create(
obj, _ = cls.objects.get_or_create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
remote_id=follow_request.remote_id,
)
return obj
class UserFollowRequest(ActivitypubMixin, UserRelationship):
@ -133,7 +134,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
raise IntegrityError(
"Attempting to follow blocked user", self.user_subject, self.user_object
)
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
@ -144,14 +147,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
if not manually_approves:
self.accept()
model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_type = "FOLLOW_REQUEST" if manually_approves else "FOLLOW"
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
def get_accept_reject_id(self, status):
"""get id for sending an accept or reject of a local user"""
@ -174,7 +169,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
if self.id:
self.delete()
def reject(self):
"""generate a Reject for this follow request"""
@ -207,3 +203,13 @@ class UserBlocks(ActivityMixin, UserRelationship):
Q(user_subject=self.user_subject, user_object=self.user_object)
| Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()
def clear_cache(user_subject, user_object):
"""clear relationship cache"""
cache.delete_many(
[
f"cached-relationship-{user_subject.id}-{user_object.id}",
f"cached-relationship-{user_object.id}-{user_subject.id}",
]
)

View file

@ -11,7 +11,7 @@ class Report(BookWyrmModel):
"User", related_name="reporter", on_delete=models.PROTECT
)
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
user = models.ForeignKey("User", on_delete=models.PROTECT, null=True, blank=True)
status = models.ForeignKey(
"Status",
null=True,

View file

@ -6,6 +6,7 @@ from django.db import models
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 . import fields
@ -17,8 +18,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
TO_READ = "to-read"
READING = "reading"
READ_FINISHED = "read"
STOPPED_READING = "stopped-reading"
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED, STOPPED_READING)
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
@ -65,6 +67,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
identifier = self.identifier or self.get_identifier()
return f"{base_path}/books/{identifier}"
@property
def local_path(self):
"""No slugs"""
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""
super().raise_not_deletable(viewer)
@ -96,12 +103,25 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
if not self.user:
self.user = self.shelf.user
if self.id and self.user.local:
cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
# remove all caches related to all editions of this book
cache.delete_many(
[
f"book-on-shelf-{book.id}-{self.shelf.id}"
for book in self.book.parent_work.editions.all()
]
)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if self.id and self.user.local:
cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
cache.delete_many(
[
f"book-on-shelf-{book}-{self.shelf.id}"
for book in self.book.parent_work.editions.values_list(
"id", flat=True
)
]
)
super().delete(*args, **kwargs)
class Meta:

View file

@ -24,6 +24,10 @@ class SiteSettings(models.Model):
)
instance_description = models.TextField(default="This instance has no description.")
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
default_theme = models.ForeignKey(
"Theme", null=True, blank=True, on_delete=models.SET_NULL
)
version = models.CharField(null=True, blank=True, max_length=10)
# admin setup options
install_mode = models.BooleanField(default=False)
@ -45,8 +49,12 @@ class SiteSettings(models.Model):
# registration
allow_registration = models.BooleanField(default=False)
allow_invite_requests = models.BooleanField(default=True)
invite_request_question = models.BooleanField(default=False)
require_confirm_email = models.BooleanField(default=True)
invite_question_text = models.CharField(
max_length=255, blank=True, default="What is your favourite book?"
)
# images
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
@ -96,14 +104,29 @@ class SiteSettings(models.Model):
return urljoin(STATIC_FULL_URL, default_path)
def save(self, *args, **kwargs):
"""if require_confirm_email is disabled, make sure no users are pending"""
"""if require_confirm_email is disabled, make sure no users are pending,
if enabled, make sure invite_question_text is not empty"""
if not self.require_confirm_email:
User.objects.filter(is_active=False, deactivation_reason="pending").update(
is_active=True, deactivation_reason=None
)
if not self.invite_question_text:
self.invite_question_text = "What is your favourite book?"
super().save(*args, **kwargs)
class Theme(models.Model):
"""Theme files"""
created_date = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=50, unique=True)
path = models.CharField(max_length=50, unique=True)
def __str__(self):
# pylint: disable=invalid-str-returned
return self.name
class SiteInvite(models.Model):
"""gives someone access to create an account on the instance"""
@ -134,6 +157,7 @@ class InviteRequest(BookWyrmModel):
invite = models.ForeignKey(
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
)
answer = models.TextField(max_length=50, unique=False, null=True, blank=True)
invite_sent = models.BooleanField(default=False)
ignored = models.BooleanField(default=False)

View file

@ -116,11 +116,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
try:
boosted = activitypub.resolve_remote_id(
activity.object, get_activity=True
)
except activitypub.ActivitySerializerError:
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
if not boosted:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
@ -221,7 +218,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""certain types of status aren't editable"""
# first, the standard raise
super().raise_not_editable(viewer)
if isinstance(self, (GeneratedNote, ReviewRating)):
# if it's an edit (not a create) you can only edit content statuses
if self.id and isinstance(self, (GeneratedNote, ReviewRating)):
raise PermissionDenied()
@classmethod
@ -265,7 +263,7 @@ class GeneratedNote(Status):
ReadingStatusChoices = models.TextChoices(
"ReadingStatusChoices", ["to-read", "reading", "read"]
"ReadingStatusChoices", ["to-read", "reading", "read", "stopped-reading"]
)
@ -306,10 +304,17 @@ class Comment(BookStatus):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
return (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
if self.progress_mode == "PG" and self.progress and (self.progress > 0):
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.progress})</p>'
)
else:
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
return return_value
activity_serializer = activitypub.Comment
@ -335,10 +340,17 @@ class Quotation(BookStatus):
"""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 (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
if self.position_mode == "PG" and self.position and (self.position > 0):
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.position}</p>{self.content}'
)
else:
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
return return_value
activity_serializer = activitypub.Quotation
@ -377,7 +389,7 @@ class Review(BookStatus):
def save(self, *args, **kwargs):
"""clear rating caches"""
if self.book.parent_work:
cache.delete(f"book-rating-{self.book.parent_work.id}-*")
cache.delete(f"book-rating-{self.book.parent_work.id}")
super().save(*args, **kwargs)

View file

@ -136,6 +136,7 @@ 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)
theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
hide_follows = fields.BooleanField(default=False)
# options to turn features on and off
@ -173,6 +174,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"])
@property
def active_follower_requests(self):
"""Follow requests from active users"""
return self.follower_requests.filter(is_active=True)
@property
def confirmation_link(self):
"""helper for generating confirmation links"""
@ -373,6 +379,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"name": "Read",
"identifier": "read",
},
{
"name": "Stopped Reading",
"identifier": "stopped-reading",
},
]
for shelf in shelves: