Merge branch 'main' into book-file-links

This commit is contained in:
Mouse Reeve
2021-12-15 10:05:29 -08:00
314 changed files with 29692 additions and 9331 deletions

View File

@ -22,6 +22,8 @@ from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .report import Report, ReportComment
from .federated_server import FederatedServer
from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, SiteInvite

View File

@ -20,7 +20,7 @@ from django.utils.http import http_date
from bookwyrm import activitypub
from bookwyrm.settings import USER_AGENT, PAGE_LENGTH
from bookwyrm.signatures import make_signature, make_digest
from bookwyrm.tasks import app
from bookwyrm.tasks import app, MEDIUM
from bookwyrm.models.fields import ImageField, ManyToManyField
logger = logging.getLogger(__name__)
@ -29,7 +29,6 @@ logger = logging.getLogger(__name__)
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
# pylint: disable=invalid-name
def set_activity_from_property_field(activity, obj, field):
"""assign a model property value to the activity json"""
@ -126,12 +125,15 @@ class ActivitypubMixin:
# there OUGHT to be only one match
return match.first()
def broadcast(self, activity, sender, software=None):
def broadcast(self, activity, sender, software=None, queue=MEDIUM):
"""send out an activity"""
broadcast_task.delay(
sender.id,
json.dumps(activity, cls=activitypub.ActivityEncoder),
self.get_recipients(software=software),
broadcast_task.apply_async(
args=(
sender.id,
json.dumps(activity, cls=activitypub.ActivityEncoder),
self.get_recipients(software=software),
),
queue=queue,
)
def get_recipients(self, software=None):
@ -195,7 +197,7 @@ class ActivitypubMixin:
class ObjectMixin(ActivitypubMixin):
"""add this mixin for object models that are AP serializable"""
def save(self, *args, created=None, **kwargs):
def save(self, *args, created=None, software=None, priority=MEDIUM, **kwargs):
"""broadcast created/updated/deleted objects as appropriate"""
broadcast = kwargs.get("broadcast", True)
# this bonus kwarg would cause an error in the base save method
@ -219,15 +221,17 @@ class ObjectMixin(ActivitypubMixin):
return
try:
software = None
# do we have a "pure" activitypub version of this for mastodon?
if hasattr(self, "pure_content"):
if software != "bookwyrm" and hasattr(self, "pure_content"):
pure_activity = self.to_create_activity(user, pure=True)
self.broadcast(pure_activity, user, software="other")
self.broadcast(
pure_activity, user, software="other", queue=priority
)
# set bookwyrm so that that type is also sent
software = "bookwyrm"
# sends to BW only if we just did a pure version for masto
activity = self.to_create_activity(user)
self.broadcast(activity, user, software=software)
self.broadcast(activity, user, software=software, queue=priority)
except AttributeError:
# janky as heck, this catches the mutliple inheritence chain
# for boosts and ignores this auxilliary broadcast
@ -241,8 +245,7 @@ class ObjectMixin(ActivitypubMixin):
if isinstance(self, user_model):
user = self
# book data tracks last editor
elif hasattr(self, "last_edited_by"):
user = self.last_edited_by
user = user or getattr(self, "last_edited_by", None)
# again, if we don't know the user or they're remote, don't bother
if not user or not user.local:
return
@ -252,7 +255,7 @@ class ObjectMixin(ActivitypubMixin):
activity = self.to_delete_activity(user)
else:
activity = self.to_update_activity(user)
self.broadcast(activity, user)
self.broadcast(activity, user, queue=priority)
def to_create_activity(self, user, **kwargs):
"""returns the object wrapped in a Create activity"""
@ -375,9 +378,9 @@ class CollectionItemMixin(ActivitypubMixin):
activity_serializer = activitypub.CollectionItem
def broadcast(self, activity, sender, software="bookwyrm"):
def broadcast(self, activity, sender, software="bookwyrm", queue=MEDIUM):
"""only send book collection updates to other bookwyrm instances"""
super().broadcast(activity, sender, software=software)
super().broadcast(activity, sender, software=software, queue=queue)
@property
def privacy(self):
@ -396,7 +399,7 @@ class CollectionItemMixin(ActivitypubMixin):
return []
return [collection_field.user]
def save(self, *args, broadcast=True, **kwargs):
def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
"""broadcast updated"""
# first off, we want to save normally no matter what
super().save(*args, **kwargs)
@ -407,7 +410,7 @@ class CollectionItemMixin(ActivitypubMixin):
# adding an obj to the collection
activity = self.to_add_activity(self.user)
self.broadcast(activity, self.user)
self.broadcast(activity, self.user, queue=priority)
def delete(self, *args, broadcast=True, **kwargs):
"""broadcast a remove activity"""
@ -440,12 +443,12 @@ class CollectionItemMixin(ActivitypubMixin):
class ActivityMixin(ActivitypubMixin):
"""add this mixin for models that are AP serializable"""
def save(self, *args, broadcast=True, **kwargs):
def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs):
"""broadcast activity"""
super().save(*args, **kwargs)
user = self.user if hasattr(self, "user") else self.user_subject
if broadcast and user.local:
self.broadcast(self.to_activity(), user)
self.broadcast(self.to_activity(), user, queue=priority)
def delete(self, *args, broadcast=True, **kwargs):
"""nevermind, undo that activity"""
@ -502,7 +505,7 @@ def unfurl_related_field(related_field, sort_field=None):
return related_field.remote_id
@app.task(queue="medium_priority")
@app.task(queue=MEDIUM)
def broadcast_task(sender_id, activity, recipients):
"""the celery task for broadcast"""
user_model = apps.get_model("bookwyrm.User", require_ready=True)

View File

@ -1,4 +1,5 @@
""" database schema for info about authors """
import re
from django.contrib.postgres.indexes import GinIndex
from django.db import models
@ -27,12 +28,23 @@ class Author(BookDataModel):
# idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)
name = fields.CharField(max_length=255, deduplication_field=True)
name = fields.CharField(max_length=255)
aliases = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = fields.HtmlField(null=True, blank=True)
@property
def isni_link(self):
"""generate the url from the isni id"""
clean_isni = re.sub(r"\s", "", self.isni)
return f"https://isni.org/isni/{clean_isni}"
@property
def openlibrary_link(self):
"""generate the url from the openlibrary id"""
return f"https://openlibrary.org/authors/{self.openlibrary_key}"
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/author/{self.id}"

View File

@ -67,19 +67,35 @@ class BookWyrmModel(models.Model):
return
# you can see the followers only posts of people you follow
if (
self.privacy == "followers"
and self.user.followers.filter(id=viewer.id).first()
if self.privacy == "followers" and (
self.user.followers.filter(id=viewer.id).first()
):
return
# you can see dms you are tagged in
if hasattr(self, "mention_users"):
if (
self.privacy == "direct"
self.privacy in ["direct", "followers"]
and self.mention_users.filter(id=viewer.id).first()
):
return
# you can see groups of which you are a member
if (
hasattr(self, "memberships")
and self.memberships.filter(user=viewer).exists()
):
return
# you can see objects which have a group of which you are a member
if hasattr(self, "group"):
if (
hasattr(self.group, "memberships")
and self.group.memberships.filter(user=viewer).exists()
):
return
raise Http404()
def raise_not_editable(self, viewer):

View File

@ -53,6 +53,16 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
null=True,
)
@property
def openlibrary_link(self):
"""generate the url from the openlibrary id"""
return f"https://openlibrary.org/books/{self.openlibrary_key}"
@property
def inventaire_link(self):
"""generate the url from the inventaire id"""
return f"https://inventaire.io/entity/{self.inventaire_id}"
class Meta:
"""can't initialize this model, that wouldn't make sense"""
@ -67,9 +77,10 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
self.remote_id = None
return super().save(*args, **kwargs)
def broadcast(self, activity, sender, software="bookwyrm"):
# pylint: disable=arguments-differ
def broadcast(self, activity, sender, software="bookwyrm", **kwargs):
"""only send book data updates to other bookwyrm instances"""
super().broadcast(activity, sender, software=software)
super().broadcast(activity, sender, software=software, **kwargs)
class Book(BookDataModel):

View File

@ -3,6 +3,7 @@ from dataclasses import MISSING
import imghdr
import re
from uuid import uuid4
from urllib.parse import urljoin
import dateutil.parser
from dateutil.parser import ParserError
@ -13,11 +14,12 @@ from django.db import models
from django.forms import ClearableFileInput, ImageField as DjangoImageField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
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.settings import DOMAIN
from bookwyrm.settings import MEDIA_FULL_URL
def validate_remote_id(value):
@ -294,7 +296,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data, overwrite=True):
"""helper function for assinging a value to the field"""
"""helper function for assigning a value to the field"""
if not overwrite and getattr(instance, self.name).exists():
return False
@ -381,17 +383,6 @@ class CustomImageField(DjangoImageField):
widget = ClearableFileInputWithWarning
def image_serializer(value, alt):
"""helper for serializing images"""
if value and hasattr(value, "url"):
url = value.url
else:
return None
if not url[:4] == "http":
url = f"https://{DOMAIN}{url}"
return activitypub.Document(url=url, name=alt)
class ImageField(ActivitypubFieldMixin, models.ImageField):
"""activitypub-aware image field"""
@ -407,7 +398,11 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if formatted is None or formatted is MISSING:
return False
if not overwrite and hasattr(instance, self.name):
if (
not overwrite
and hasattr(instance, self.name)
and getattr(instance, self.name)
):
return False
getattr(instance, self.name).save(*formatted, save=save)
@ -424,7 +419,12 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
activity[key] = formatted
def field_to_activity(self, value, alt=None):
return image_serializer(value, alt)
url = get_absolute_url(value)
if not url:
return None
return activitypub.Document(url=url, name=alt)
def field_from_activity(self, value):
image_slug = value
@ -461,6 +461,20 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
)
def get_absolute_url(value):
"""returns an absolute URL for the image"""
name = getattr(value, "name")
if not name:
return None
url = filepath_to_uri(name)
if url is not None:
url = url.lstrip("/")
url = urljoin(MEDIA_FULL_URL, url)
return url
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
"""activitypub-aware datetime field"""

181
bookwyrm/models/group.py Normal file
View File

@ -0,0 +1,181 @@
""" do book related things with other users """
from django.apps import apps
from django.db import models, IntegrityError, transaction
from django.db.models import Q
from bookwyrm.settings import DOMAIN
from .base_model import BookWyrmModel
from . import fields
from .relationship import UserBlocks
class Group(BookWyrmModel):
"""A group of users"""
name = fields.CharField(max_length=100)
user = fields.ForeignKey("User", on_delete=models.CASCADE)
description = fields.TextField(blank=True, null=True)
privacy = fields.PrivacyField()
def get_remote_id(self):
"""don't want the user to be in there in this case"""
return f"https://{DOMAIN}/group/{self.id}"
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override filter for "followers" privacy level to allow non-following
group members to see the existence of group-curated lists"""
return queryset.exclude(
~Q( # user is not a group member
Q(user__followers=viewer) | Q(user=viewer) | Q(memberships__user=viewer)
),
privacy="followers", # and the status of the group is followers only
)
@classmethod
def direct_filter(cls, queryset, viewer):
"""Override filter for "direct" privacy level to allow group members
to see the existence of groups and group lists"""
return queryset.exclude(~Q(memberships__user=viewer), privacy="direct")
class GroupMember(models.Model):
"""Users who are members of a group"""
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
group = models.ForeignKey(
"Group", on_delete=models.CASCADE, related_name="memberships"
)
user = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="memberships"
)
class Meta:
"""Users can only have one membership per group"""
constraints = [
models.UniqueConstraint(fields=["group", "user"], name="unique_membership")
]
def save(self, *args, **kwargs):
"""don't let a user invite someone who blocked them"""
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.group.user,
user_object=self.user,
)
| Q(
user_subject=self.user,
user_object=self.group.user,
)
).exists():
raise IntegrityError()
# accepts and requests are handled by the GroupMemberInvitation model
super().save(*args, **kwargs)
@classmethod
def from_request(cls, join_request):
"""converts a join request into a member relationship"""
# remove the invite
join_request.delete()
# make a group member
return cls.objects.create(
user=join_request.user,
group=join_request.group,
)
@classmethod
def remove(cls, owner, user):
"""remove a user from a group"""
memberships = cls.objects.filter(group__user=owner, user=user).all()
for member in memberships:
member.delete()
class GroupMemberInvitation(models.Model):
"""adding a user to a group requires manual confirmation"""
created_date = models.DateTimeField(auto_now_add=True)
group = models.ForeignKey(
"Group", on_delete=models.CASCADE, related_name="user_invitations"
)
user = models.ForeignKey(
"User", on_delete=models.CASCADE, related_name="group_invitations"
)
class Meta:
"""Users can only have one outstanding invitation per group"""
constraints = [
models.UniqueConstraint(fields=["group", "user"], name="unique_invitation")
]
def save(self, *args, **kwargs):
"""make sure the membership doesn't already exist"""
# if there's an invitation for a membership that already exists, accept it
# without changing the local database state
if GroupMember.objects.filter(user=self.user, group=self.group).exists():
self.accept()
return
# blocking in either direction is a no-go
if UserBlocks.objects.filter(
Q(
user_subject=self.group.user,
user_object=self.user,
)
| Q(
user_subject=self.user,
user_object=self.group.user,
)
).exists():
raise IntegrityError()
# make an invitation
super().save(*args, **kwargs)
# now send the invite
model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_type = "INVITE"
model.objects.create(
user=self.user,
related_user=self.group.user,
related_group=self.group,
notification_type=notification_type,
)
@transaction.atomic
def accept(self):
"""turn this request into the real deal"""
GroupMember.from_request(self)
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
model.objects.create(
user=self.group.user,
related_user=self.user,
related_group=self.group,
notification_type="ACCEPT",
)
# let the other members know about it
for membership in self.group.memberships.all():
member = membership.user
if member not in (self.user, self.group.user):
model.objects.create(
user=member,
related_user=self.user,
related_group=self.group,
notification_type="JOIN",
)
def reject(self):
"""generate a Reject for this membership request"""
self.delete()

View File

@ -6,20 +6,14 @@ from django.db import models
from django.utils import timezone
from bookwyrm.connectors import connector_manager
from bookwyrm.models import ReadThrough, User, Book
from bookwyrm.models import ReadThrough, User, Book, Edition
from .fields import PrivacyLevels
# Mapping goodreads -> bookwyrm shelf titles.
GOODREADS_SHELVES = {
"read": "read",
"currently-reading": "reading",
"to-read": "to-read",
}
def unquote_string(text):
"""resolve csv quote weirdness"""
if not text:
return None
match = re.match(r'="([^"]*)"', text)
if match:
return match.group(1)
@ -31,7 +25,7 @@ def construct_search_term(title, author):
# Strip brackets (usually series title from search term)
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
# Open library doesn't like including author initials in search term.
author = re.sub(r"(\w\.)+\s*", "", author)
author = re.sub(r"(\w\.)+\s*", "", author) if author else ""
return " ".join([title, author])
@ -41,14 +35,21 @@ class ImportJob(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
created_date = models.DateTimeField(default=timezone.now)
task_id = models.CharField(max_length=100, null=True)
updated_date = models.DateTimeField(default=timezone.now)
include_reviews = models.BooleanField(default=True)
mappings = models.JSONField()
complete = models.BooleanField(default=False)
source = models.CharField(max_length=100)
privacy = models.CharField(
max_length=255, default="public", choices=PrivacyLevels.choices
)
retry = models.BooleanField(default=False)
@property
def pending_items(self):
"""items that haven't been processed yet"""
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
class ImportItem(models.Model):
"""a single line of a csv being imported"""
@ -56,6 +57,7 @@ class ImportItem(models.Model):
job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name="items")
index = models.IntegerField()
data = models.JSONField()
normalized_data = models.JSONField()
book = models.ForeignKey(Book, on_delete=models.SET_NULL, null=True, blank=True)
book_guess = models.ForeignKey(
Book,
@ -65,11 +67,30 @@ class ImportItem(models.Model):
related_name="book_guess",
)
fail_reason = models.TextField(null=True)
linked_review = models.ForeignKey(
"Review", on_delete=models.SET_NULL, null=True, blank=True
)
def update_job(self):
"""let the job know when the items get work done"""
job = self.job
job.updated_date = timezone.now()
job.save()
if not job.pending_items.exists() and not job.complete:
job.complete = True
job.save(update_fields=["complete"])
def resolve(self):
"""try various ways to lookup a book"""
# we might be calling this after manually adding the book,
# so no need to do searches
if self.book:
return
if self.isbn:
self.book = self.get_book_from_isbn()
self.book = self.get_book_from_identifier()
elif self.openlibrary_key:
self.book = self.get_book_from_identifier(field="openlibrary_key")
else:
# don't fall back on title/author search if isbn is present.
# you're too likely to mismatch
@ -79,23 +100,31 @@ class ImportItem(models.Model):
else:
self.book_guess = book
def get_book_from_isbn(self):
"""search by isbn"""
def get_book_from_identifier(self, field="isbn"):
"""search by isbn or other unique identifier"""
search_result = connector_manager.first_search_result(
self.isbn, min_confidence=0.999
getattr(self, field), min_confidence=0.999
)
if search_result:
# it's already in the right format
if isinstance(search_result, Edition):
return search_result
# it's just a search result, book needs to be created
# raises ConnectorException
return search_result.connector.get_or_create_book(search_result.key)
return None
def get_book_from_title_author(self):
"""search by title and author"""
if not self.title:
return None, 0
search_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result(
search_term, min_confidence=0.1
)
if search_result:
if isinstance(search_result, Edition):
return (search_result, 1)
# raises ConnectorException
return (
search_result.connector.get_or_create_book(search_result.key),
@ -106,56 +135,69 @@ class ImportItem(models.Model):
@property
def title(self):
"""get the book title"""
return self.data["Title"]
return self.normalized_data.get("title")
@property
def author(self):
"""get the book title"""
return self.data["Author"]
"""get the book's authors"""
return self.normalized_data.get("authors")
@property
def isbn(self):
"""pulls out the isbn13 field from the csv line data"""
return unquote_string(self.data["ISBN13"])
return unquote_string(self.normalized_data.get("isbn_13")) or unquote_string(
self.normalized_data.get("isbn_10")
)
@property
def openlibrary_key(self):
"""the edition identifier is preferable to the work key"""
return self.normalized_data.get("openlibrary_key") or self.normalized_data.get(
"openlibrary_work_key"
)
@property
def shelf(self):
"""the goodreads shelf field"""
if self.data["Exclusive Shelf"]:
return GOODREADS_SHELVES.get(self.data["Exclusive Shelf"])
return None
return self.normalized_data.get("shelf")
@property
def review(self):
"""a user-written review, to be imported with the book data"""
return self.data["My Review"]
return self.normalized_data.get("review_body")
@property
def rating(self):
"""x/5 star rating for a book"""
if self.data.get("My Rating", None):
return int(self.data["My Rating"])
if self.normalized_data.get("rating"):
return float(self.normalized_data.get("rating"))
return None
@property
def date_added(self):
"""when the book was added to this dataset"""
if self.data["Date Added"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Added"]))
if self.normalized_data.get("date_added"):
return timezone.make_aware(
dateutil.parser.parse(self.normalized_data.get("date_added"))
)
return None
@property
def date_started(self):
"""when the book was started"""
if "Date Started" in self.data and self.data["Date Started"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Started"]))
if self.normalized_data.get("date_started"):
return timezone.make_aware(
dateutil.parser.parse(self.normalized_data.get("date_started"))
)
return None
@property
def date_read(self):
"""the date a book was completed"""
if self.data["Date Read"]:
return timezone.make_aware(dateutil.parser.parse(self.data["Date Read"]))
if self.normalized_data.get("date_finished"):
return timezone.make_aware(
dateutil.parser.parse(self.normalized_data.get("date_finished"))
)
return None
@property
@ -174,7 +216,9 @@ 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
start_date = (
start_date if start_date and start_date < self.date_read else None
)
return [
ReadThrough(
start_date=start_date,
@ -185,8 +229,10 @@ class ImportItem(models.Model):
def __repr__(self):
# pylint: disable=consider-using-f-string
return "<{!r}Item {!r}>".format(self.data["import_source"], self.data["Title"])
return "<{!r} Item {!r}>".format(self.index, self.normalized_data.get("title"))
def __str__(self):
# pylint: disable=consider-using-f-string
return "{} by {}".format(self.data["Title"], self.data["Author"])
return "{} by {}".format(
self.normalized_data.get("title"), self.normalized_data.get("authors")
)

View File

@ -1,22 +1,22 @@
""" make a list of books!! """
import uuid
from django.apps import apps
from django.db import models
from django.db.models import Q
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from .group import GroupMember
from . import fields
CurationType = models.TextChoices(
"Curation",
[
"closed",
"open",
"curated",
],
["closed", "open", "curated", "group"],
)
@ -32,12 +32,20 @@ class List(OrderedCollectionMixin, BookWyrmModel):
curation = fields.CharField(
max_length=255, default="closed", choices=CurationType.choices
)
group = models.ForeignKey(
"Group",
on_delete=models.SET_NULL,
default=None,
blank=True,
null=True,
)
books = models.ManyToManyField(
"Edition",
symmetrical=False,
through="ListItem",
through_fields=("book_list", "book"),
)
embed_key = models.UUIDField(unique=True, null=True, editable=False)
activity_serializer = activitypub.BookList
def get_remote_id(self):
@ -54,6 +62,58 @@ class List(OrderedCollectionMixin, BookWyrmModel):
ordering = ("-updated_date",)
def raise_not_editable(self, viewer):
"""the associated user OR the list owner can edit"""
if self.user == viewer:
return
# group members can edit items in group lists
is_group_member = GroupMember.objects.filter(
group=self.group, user=viewer
).exists()
if is_group_member:
return
super().raise_not_editable(viewer)
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override filter for "followers" privacy level to allow non-following
group members to see the existence of group lists"""
return queryset.exclude(
~Q( # user isn't following or group member
Q(user__followers=viewer)
| Q(user=viewer)
| Q(group__memberships__user=viewer)
),
privacy="followers", # and the status (of the list) is followers only
)
@classmethod
def direct_filter(cls, queryset, viewer):
"""Override filter for "direct" privacy level to allow
group members to see the existence of group lists"""
return queryset.exclude(
~Q( # user not self and not in the group if this is a group list
Q(user=viewer) | Q(group__memberships__user=viewer)
),
privacy="direct",
)
@classmethod
def remove_from_group(cls, owner, user):
"""remove a list from a group"""
cls.objects.filter(group__user=owner, user=user).all().update(
group=None, curation="closed"
)
def save(self, *args, **kwargs):
"""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)
class ListItem(CollectionItemMixin, BookWyrmModel):
"""ok"""
@ -82,9 +142,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
self.book_list.save(broadcast=False)
list_owner = self.book_list.user
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
model = apps.get_model("bookwyrm.Notification", require_ready=True)
model.objects.create(
user=list_owner,
related_user=self.user,
@ -92,10 +152,26 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
notification_type="ADD",
)
if self.book_list.group:
for membership in self.book_list.group.memberships.all():
if membership.user != self.user:
model.objects.create(
user=membership.user,
related_user=self.user,
related_list_item=self,
notification_type="ADD",
)
def raise_not_deletable(self, viewer):
"""the associated user OR the list owner can delete"""
if self.book_list.user == viewer:
return
# group members can delete items in group lists
is_group_member = GroupMember.objects.filter(
group=self.book_list.group, user=viewer
).exists()
if is_group_member:
return
super().raise_not_deletable(viewer)
class Meta:

View File

@ -4,10 +4,10 @@ from django.dispatch import receiver
from .base_model import BookWyrmModel
from . import Boost, Favorite, ImportJob, Report, Status, User
# pylint: disable=line-too-long
NotificationType = models.TextChoices(
"NotificationType",
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT",
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE GROUP_PRIVACY GROUP_NAME GROUP_DESCRIPTION",
)
@ -19,6 +19,9 @@ class Notification(BookWyrmModel):
related_user = models.ForeignKey(
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
)
related_group = models.ForeignKey(
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
@ -37,6 +40,7 @@ class Notification(BookWyrmModel):
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
related_group=self.related_group,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,
@ -153,9 +157,12 @@ def notify_user_on_unboost(sender, instance, *args, **kwargs):
@receiver(models.signals.post_save, sender=ImportJob)
# pylint: disable=unused-argument
def notify_user_on_import_complete(sender, instance, *args, **kwargs):
def notify_user_on_import_complete(
sender, instance, *args, update_fields=None, **kwargs
):
"""we imported your books! aren't you proud of us"""
if not instance.complete:
update_fields = update_fields or []
if not instance.complete or "complete" not in update_fields:
return
Notification.objects.create(
user=instance.user,

View File

@ -1,5 +1,6 @@
""" the particulars for this instance of BookWyrm """
import datetime
from urllib.parse import urljoin
from django.db import models, IntegrityError
from django.dispatch import receiver
@ -7,9 +8,10 @@ from django.utils import timezone
from model_utils import FieldTracker
from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from .base_model import BookWyrmModel, new_access_code
from .user import User
from .fields import get_absolute_url
class SiteSettings(models.Model):
@ -66,6 +68,28 @@ class SiteSettings(models.Model):
default_settings.save()
return default_settings
@property
def logo_url(self):
"""helper to build the logo url"""
return self.get_url("logo", "images/logo.png")
@property
def logo_small_url(self):
"""helper to build the logo url"""
return self.get_url("logo_small", "images/logo-small.png")
@property
def favicon_url(self):
"""helper to build the logo url"""
return self.get_url("favicon", "images/favicon.png")
def get_url(self, field, default_path):
"""get a media url or a default static path"""
uploaded = getattr(self, field, None)
if uploaded:
return get_absolute_url(uploaded)
return urljoin(STATIC_FULL_URL, default_path)
class SiteInvite(models.Model):
"""gives someone access to create an account on the instance"""

View File

@ -19,7 +19,6 @@ from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
from .fields import image_serializer
from .readthrough import ProgressMode
from . import fields
@ -31,6 +30,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"User", on_delete=models.PROTECT, activitypub_field="attributedTo"
)
content = fields.HtmlField(blank=True, null=True)
raw_content = models.TextField(blank=True, null=True)
mention_users = fields.TagField("User", related_name="mention_user")
mention_books = fields.TagField("Edition", related_name="mention_book")
local = models.BooleanField(default=True)
@ -43,6 +43,9 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
published_date = fields.DateTimeField(
default=timezone.now, activitypub_field="published"
)
edited_date = fields.DateTimeField(
blank=True, null=True, activitypub_field="updated"
)
deleted = models.BooleanField(default=False)
deleted_date = models.DateTimeField(blank=True, null=True)
favorites = models.ManyToManyField(
@ -186,15 +189,26 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if hasattr(activity, "name"):
activity.name = self.pure_name
activity.type = self.pure_type
activity.attachment = [
image_serializer(b.cover, b.alt_text)
for b in self.mention_books.all()[:4]
if b.cover
]
if hasattr(self, "book") and self.book.cover:
activity.attachment.append(
image_serializer(self.book.cover, self.book.alt_text)
)
book = getattr(self, "book", None)
books = [book] if book else []
books += list(self.mention_books.all())
if len(books) == 1 and getattr(books[0], "preview_image", None):
covers = [
activitypub.Document(
url=fields.get_absolute_url(books[0].preview_image),
name=books[0].alt_text,
)
]
else:
covers = [
activitypub.Document(
url=fields.get_absolute_url(b.cover),
name=b.alt_text,
)
for b in books
if b and b.cover
]
activity.attachment = covers
return activity
def to_activity(self, pure=False): # pylint: disable=arguments-differ
@ -220,6 +234,16 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
~Q(Q(user=viewer) | Q(mention_users=viewer)), privacy="direct"
)
@classmethod
def followers_filter(cls, queryset, viewer):
"""Override-able filter for "followers" privacy level"""
return queryset.exclude(
~Q( # not yourself, a follower, or someone who is tagged
Q(user__followers=viewer) | Q(user=viewer) | Q(mention_users=viewer)
),
privacy="followers", # and the status is followers only
)
class GeneratedNote(Status):
"""these are app-generated messages about user activity"""
@ -292,6 +316,7 @@ class Quotation(BookStatus):
"""like a review but without a rating and transient"""
quote = fields.HtmlField()
raw_quote = models.TextField(blank=True, null=True)
position = models.IntegerField(
validators=[MinValueValidator(0)], null=True, blank=True
)

View File

@ -4,11 +4,12 @@ from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import CICharField
from django.contrib.postgres.fields import ArrayField, CICharField
from django.core.validators import MinValueValidator
from django.dispatch import receiver
from django.db import models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
import pytz
@ -27,6 +28,19 @@ from .federated_server import FederatedServer
from . import fields, Review
FeedFilterChoices = [
("review", _("Reviews")),
("comment", _("Comments")),
("quotation", _("Quotations")),
("everything", _("Everything else")),
]
def get_feed_filter_choices():
"""return a list of filter choice keys"""
return [f[0] for f in FeedFilterChoices]
def site_link():
"""helper for generating links to the site"""
protocol = "https" if USE_HTTPS else "http"
@ -128,6 +142,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
# feed options
feed_status_types = ArrayField(
models.CharField(max_length=10, blank=False, choices=FeedFilterChoices),
size=8,
default=get_feed_filter_choices,
)
preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
default=str(pytz.utc),