Merge branch 'main' into book-format-choices

This commit is contained in:
Mouse Reeve
2021-09-27 10:28:05 -07:00
199 changed files with 7647 additions and 3289 deletions

View File

@ -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
@ -24,8 +23,12 @@ 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 .announcement import Announcement
from .antispam import EmailBlocklist, IPBlocklist
from .notification import Notification
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
activity_models = {

View File

@ -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)
@ -420,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,
@ -430,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,
@ -458,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()
@ -502,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)
@ -555,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,

View 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",)

View File

@ -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

View File

@ -3,20 +3,19 @@ import base64
from Crypto import Random
from django.db import models
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
DeactivationReason = models.TextChoices(
"DeactivationReason",
[
"pending",
"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():
@ -33,11 +32,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"""
@ -47,7 +46,7 @@ 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):
"""is a user authorized to view an object?"""

View File

@ -4,6 +4,7 @@ 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 django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
@ -164,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):
@ -177,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,
@ -216,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,
)
@ -375,4 +377,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)
)

View File

@ -19,7 +19,7 @@ class Connector(BookWyrmModel):
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 +29,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})"

View File

@ -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, update_fields=["last_active_date"])
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"""

View File

@ -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

View File

@ -56,7 +56,7 @@ class ActivitypubFieldMixin:
activitypub_field=None,
activitypub_wrapper=None,
deduplication_field=False,
**kwargs
**kwargs,
):
self.deduplication_field = deduplication_field
if activitypub_wrapper:
@ -308,7 +308,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
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):
@ -388,7 +388,7 @@ def image_serializer(value, alt):
else:
return None
if not url[:4] == "http":
url = "https://{:s}{:s}".format(DOMAIN, url)
url = f"https://{DOMAIN}{url}"
return activitypub.Document(url=url, name=alt)
@ -448,7 +448,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):

View File

@ -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.book = 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"])

View File

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

View File

@ -1,6 +1,8 @@
""" 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
NotificationType = models.TextChoices(
@ -53,3 +55,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",
)

View File

@ -2,7 +2,6 @@
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
@ -30,8 +29,7 @@ class ReadThrough(BookWyrmModel):
def save(self, *args, **kwargs):
"""update user active time"""
self.user.last_active_date = timezone.now()
self.user.save(broadcast=False, update_fields=["last_active_date"])
self.user.update_active_date()
super().save(*args, **kwargs)
def create_update(self):
@ -65,6 +63,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, update_fields=["last_active_date"])
self.user.update_active_date()
super().save(*args, **kwargs)

View File

@ -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"""

View File

@ -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"""

View File

@ -44,7 +44,7 @@ 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):
@ -55,7 +55,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
"""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}"
class Meta:
"""user/shelf unqiueness"""

View File

@ -20,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.")
@ -80,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):
@ -120,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

View File

@ -67,40 +67,6 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
ordering = ("-published_date",)
def save(self, *args, **kwargs):
"""save and notify"""
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,
)
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" "delete" a status"""
if hasattr(self, "boosted_status"):
@ -108,6 +74,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 +149,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
@ -226,10 +196,10 @@ 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"
@ -277,10 +247,9 @@ class Comment(BookStatus):
@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
@ -306,11 +275,9 @@ 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 '%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
@ -389,27 +356,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" """
@ -422,10 +368,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)

View File

@ -105,7 +105,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
related_name="blocked_by",
)
saved_lists = models.ManyToManyField(
"List", symmetrical=False, related_name="saved_lists"
"List", symmetrical=False, related_name="saved_lists", blank=True
)
favorites = models.ManyToManyField(
"Status",
@ -122,16 +122,21 @@ 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),
max_length=255,
)
deactivation_reason = models.CharField(
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
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"
@ -147,12 +152,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
@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):
@ -189,12 +195,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"
@ -218,7 +227,7 @@ 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,
@ -261,10 +270,15 @@ 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)
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
@ -274,30 +288,47 @@ class User(OrderedCollectionPageMixin, AbstractUser):
transaction.on_commit(lambda: set_remote_server.delay(self.id))
return
# 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"
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"""
# 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",
@ -321,17 +352,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"""
@ -346,7 +366,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"""
@ -383,7 +403,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):
@ -420,7 +440,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)
@ -439,7 +459,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):
@ -459,7 +479,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"