Merge branch 'main' into book-format-choices

This commit is contained in:
Mouse Reeve
2021-09-06 22:13:24 -07:00
283 changed files with 8674 additions and 4219 deletions

View File

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

View File

@ -1,4 +1,6 @@
""" base model with default fields """
import base64
from Crypto import Random
from django.db import models
from django.dispatch import receiver
@ -9,6 +11,7 @@ from .fields import RemoteIdField
DeactivationReason = models.TextChoices(
"DeactivationReason",
[
"pending",
"self_deletion",
"moderator_deletion",
"domain_block",
@ -16,6 +19,11 @@ DeactivationReason = models.TextChoices(
)
def new_access_code():
"""the identifier for a user invite"""
return base64.b32encode(Random.get_random_bytes(5)).decode("ascii")
class BookWyrmModel(models.Model):
"""shared fields"""

View File

@ -7,10 +7,16 @@ from django.db import models
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 +103,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"""

View File

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

View File

@ -80,7 +80,7 @@ class ImportItem(models.Model):
else:
# don't fall back on title/author search is isbn is present.
# you're too likely to mismatch
self.get_book_from_title_author()
self.book = self.get_book_from_title_author()
def get_book_from_isbn(self):
"""search by isbn"""

View File

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

View File

@ -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
@ -33,6 +31,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 +60,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"""

View File

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

View File

@ -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
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"
)
favorites = models.ManyToManyField(
"Status",
symmetrical=False,
@ -111,7 +120,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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)
show_goal = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
@ -123,11 +132,18 @@ class User(OrderedCollectionPageMixin, AbstractUser):
deactivation_reason = models.CharField(
max_length=255, choices=DeactivationReason.choices, 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"""
@ -207,17 +223,17 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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):
@ -259,10 +275,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
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
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
super().save(*args, **kwargs)