Merge branch 'main' into logo-default
This commit is contained in:
@ -2,15 +2,18 @@
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
from .book import Book, Work, Edition
|
||||
from .book import Book, Work, Edition, BookDataModel
|
||||
from .author import Author
|
||||
from .connector import Connector
|
||||
|
||||
from .shelf import Shelf, ShelfBook
|
||||
|
||||
from .status import Status, GeneratedNote, Review, Comment, Quotation
|
||||
from .status import Favorite, Boost, Notification, ReadThrough
|
||||
from .status import Boost
|
||||
from .attachment import Image
|
||||
from .favorite import Favorite
|
||||
from .notification import Notification
|
||||
from .readthrough import ReadThrough
|
||||
|
||||
from .tag import Tag, UserTag
|
||||
|
||||
@ -26,7 +29,5 @@ cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_models = {c[1].activity_serializer.__name__: c[1] \
|
||||
for c in cls_members if hasattr(c[1], 'activity_serializer')}
|
||||
|
||||
def to_activity(activity_json):
|
||||
''' link up models and activities '''
|
||||
activity_type = activity_json.get('type')
|
||||
return activity_models[activity_type].to_activity(activity_json)
|
||||
status_models = [
|
||||
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
|
||||
|
@ -1,40 +1,25 @@
|
||||
''' database schema for info about authors '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .book import BookDataModel
|
||||
from . import fields
|
||||
|
||||
|
||||
class Author(ActivitypubMixin, BookWyrmModel):
|
||||
class Author(BookDataModel):
|
||||
''' basic biographic info '''
|
||||
origin_id = models.CharField(max_length=255, null=True)
|
||||
openlibrary_key = fields.CharField(
|
||||
wikipedia_link = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True)
|
||||
sync = models.BooleanField(default=True)
|
||||
last_sync_date = models.DateTimeField(default=timezone.now)
|
||||
wikipedia_link = fields.CharField(max_length=255, blank=True, null=True, deduplication_field=True)
|
||||
# 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)
|
||||
name = fields.CharField(max_length=255, deduplication_field=True)
|
||||
aliases = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
bio = fields.TextField(null=True, blank=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
||||
if self.id and not self.remote_id:
|
||||
self.remote_id = self.get_remote_id()
|
||||
|
||||
if not self.id:
|
||||
self.origin_id = self.remote_id
|
||||
self.remote_id = None
|
||||
return super().save(*args, **kwargs)
|
||||
bio = fields.HtmlField(null=True, blank=True)
|
||||
|
||||
def get_remote_id(self):
|
||||
''' editions and works both use "book" instead of model_name '''
|
||||
|
@ -14,16 +14,9 @@ from django.dispatch import receiver
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN, PAGE_LENGTH
|
||||
from .fields import RemoteIdField
|
||||
from .fields import ImageField, ManyToManyField, RemoteIdField
|
||||
|
||||
|
||||
PrivacyLevels = models.TextChoices('Privacy', [
|
||||
'public',
|
||||
'unlisted',
|
||||
'followers',
|
||||
'direct'
|
||||
])
|
||||
|
||||
class BookWyrmModel(models.Model):
|
||||
''' shared fields '''
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
@ -42,8 +35,14 @@ class BookWyrmModel(models.Model):
|
||||
''' this is just here to provide default fields for other models '''
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
''' how to link to this object in the local app '''
|
||||
return self.get_remote_id().replace('https://%s' % DOMAIN, '')
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
#pylint: disable=unused-argument
|
||||
def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||
''' set the remote_id after save (when the id is available) '''
|
||||
if not created or not hasattr(instance, 'get_remote_id'):
|
||||
@ -67,6 +66,33 @@ class ActivitypubMixin:
|
||||
activity_serializer = lambda: {}
|
||||
reverse_unfurl = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
''' collect some info on model fields '''
|
||||
self.image_fields = []
|
||||
self.many_to_many_fields = []
|
||||
self.simple_fields = [] # "simple"
|
||||
for field in self._meta.get_fields():
|
||||
if not hasattr(field, 'field_to_activity'):
|
||||
continue
|
||||
|
||||
if isinstance(field, ImageField):
|
||||
self.image_fields.append(field)
|
||||
elif isinstance(field, ManyToManyField):
|
||||
self.many_to_many_fields.append(field)
|
||||
else:
|
||||
self.simple_fields.append(field)
|
||||
|
||||
self.activity_fields = self.image_fields + \
|
||||
self.many_to_many_fields + self.simple_fields
|
||||
|
||||
self.deserialize_reverse_fields = self.deserialize_reverse_fields \
|
||||
if hasattr(self, 'deserialize_reverse_fields') else []
|
||||
self.serialize_reverse_fields = self.serialize_reverse_fields \
|
||||
if hasattr(self, 'serialize_reverse_fields') else []
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@classmethod
|
||||
def find_existing_by_remote_id(cls, remote_id):
|
||||
''' look up a remote id in the db '''
|
||||
@ -83,7 +109,7 @@ class ActivitypubMixin:
|
||||
not field.deduplication_field:
|
||||
continue
|
||||
|
||||
value = data.get(field.activitypub_field)
|
||||
value = data.get(field.get_activitypub_field())
|
||||
if not value:
|
||||
continue
|
||||
filters.append({field.name: value})
|
||||
@ -114,19 +140,8 @@ class ActivitypubMixin:
|
||||
def to_activity(self):
|
||||
''' convert from a model to an activity '''
|
||||
activity = {}
|
||||
for field in self._meta.get_fields():
|
||||
if not hasattr(field, 'field_to_activity'):
|
||||
continue
|
||||
value = field.field_to_activity(getattr(self, field.name))
|
||||
if value is None:
|
||||
continue
|
||||
|
||||
key = field.get_activitypub_field()
|
||||
if key in activity and isinstance(activity[key], list):
|
||||
# handles tags on status, which accumulate across fields
|
||||
activity[key] += value
|
||||
else:
|
||||
activity[key] = value
|
||||
for field in self.activity_fields:
|
||||
field.set_activity_from_field(activity, self)
|
||||
|
||||
if hasattr(self, 'serialize_reverse_fields'):
|
||||
# for example, editions of a work
|
||||
@ -141,9 +156,9 @@ class ActivitypubMixin:
|
||||
return self.activity_serializer(**activity).serialize()
|
||||
|
||||
|
||||
def to_create_activity(self, user):
|
||||
def to_create_activity(self, user, **kwargs):
|
||||
''' returns the object wrapped in a Create activity '''
|
||||
activity_object = self.to_activity()
|
||||
activity_object = self.to_activity(**kwargs)
|
||||
|
||||
signer = pkcs1_15.new(RSA.import_key(user.key_pair.private_key))
|
||||
content = activity_object['content']
|
||||
@ -227,7 +242,9 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
|
||||
).serialize()
|
||||
|
||||
|
||||
def to_ordered_collection_page(queryset, remote_id, id_only=False, page=1):
|
||||
# pylint: disable=unused-argument
|
||||
def to_ordered_collection_page(
|
||||
queryset, remote_id, id_only=False, page=1, **kwargs):
|
||||
''' serialize and pagiante a queryset '''
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
|
||||
|
@ -2,7 +2,6 @@
|
||||
import re
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from bookwyrm import activitypub
|
||||
@ -12,10 +11,9 @@ from .base_model import BookWyrmModel
|
||||
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
||||
from . import fields
|
||||
|
||||
class Book(ActivitypubMixin, BookWyrmModel):
|
||||
''' a generic book, which can mean either an edition or a work '''
|
||||
class BookDataModel(ActivitypubMixin, BookWyrmModel):
|
||||
''' fields shared between editable book data (books, works, authors) '''
|
||||
origin_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
# these identifiers apply to both works and editions
|
||||
openlibrary_key = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True)
|
||||
librarything_key = fields.CharField(
|
||||
@ -23,20 +21,33 @@ class Book(ActivitypubMixin, BookWyrmModel):
|
||||
goodreads_key = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True)
|
||||
|
||||
# info about where the data comes from and where/if to sync
|
||||
sync = models.BooleanField(default=True)
|
||||
sync_cover = models.BooleanField(default=True)
|
||||
last_sync_date = models.DateTimeField(default=timezone.now)
|
||||
last_edited_by = models.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, null=True)
|
||||
|
||||
class Meta:
|
||||
''' can't initialize this model, that wouldn't make sense '''
|
||||
abstract = True
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' ensure that the remote_id is within this instance '''
|
||||
if self.id:
|
||||
self.remote_id = self.get_remote_id()
|
||||
else:
|
||||
self.origin_id = self.remote_id
|
||||
self.remote_id = None
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Book(BookDataModel):
|
||||
''' a generic book, which can mean either an edition or a work '''
|
||||
connector = models.ForeignKey(
|
||||
'Connector', on_delete=models.PROTECT, null=True)
|
||||
|
||||
# TODO: edit history
|
||||
|
||||
# book/work metadata
|
||||
title = fields.CharField(max_length=255)
|
||||
sort_title = fields.CharField(max_length=255, blank=True, null=True)
|
||||
subtitle = fields.CharField(max_length=255, blank=True, null=True)
|
||||
description = fields.TextField(blank=True, null=True)
|
||||
description = fields.HtmlField(blank=True, null=True)
|
||||
languages = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
@ -48,27 +59,42 @@ class Book(ActivitypubMixin, BookWyrmModel):
|
||||
subject_places = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, null=True, default=list
|
||||
)
|
||||
# TODO: include an annotation about the type of authorship (ie, translator)
|
||||
authors = fields.ManyToManyField('Author')
|
||||
# preformatted authorship string for search and easier display
|
||||
author_text = models.CharField(max_length=255, blank=True, null=True)
|
||||
cover = fields.ImageField(upload_to='covers/', blank=True, null=True)
|
||||
cover = fields.ImageField(
|
||||
upload_to='covers/', blank=True, null=True, alt_field='alt_text')
|
||||
first_published_date = fields.DateTimeField(blank=True, null=True)
|
||||
published_date = fields.DateTimeField(blank=True, null=True)
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
@property
|
||||
def author_text(self):
|
||||
''' format a list of authors '''
|
||||
return ', '.join(a.name for a in self.authors.all())
|
||||
|
||||
@property
|
||||
def edition_info(self):
|
||||
''' 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' else None,
|
||||
str(self.published_date.year) if self.published_date else None,
|
||||
]
|
||||
return ', '.join(i for i in items if i)
|
||||
|
||||
@property
|
||||
def alt_text(self):
|
||||
''' image alt test '''
|
||||
text = '%s cover' % self.title
|
||||
if self.edition_info:
|
||||
text += ' (%s)' % self.edition_info
|
||||
return text
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' can't be abstract for query reasons, but you shouldn't USE it '''
|
||||
if not isinstance(self, Edition) and not isinstance(self, Work):
|
||||
raise ValueError('Books should be added as Editions or Works')
|
||||
|
||||
if self.id and not self.remote_id:
|
||||
self.remote_id = self.get_remote_id()
|
||||
|
||||
if not self.id:
|
||||
self.origin_id = self.remote_id
|
||||
self.remote_id = None
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_remote_id(self):
|
||||
@ -92,13 +118,22 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||
default_edition = fields.ForeignKey(
|
||||
'Edition',
|
||||
on_delete=models.PROTECT,
|
||||
null=True
|
||||
null=True,
|
||||
load_remote=False
|
||||
)
|
||||
|
||||
def get_default_edition(self):
|
||||
''' in case the default edition is not set '''
|
||||
return self.default_edition or self.editions.first()
|
||||
|
||||
def to_edition_list(self, **kwargs):
|
||||
''' an ordered collection of editions '''
|
||||
return self.to_ordered_collection(
|
||||
self.editions.order_by('-updated_date').all(),
|
||||
remote_id='%s/editions' % self.remote_id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Work
|
||||
serialize_reverse_fields = [('editions', 'editions')]
|
||||
deserialize_reverse_fields = [('editions', 'editions')]
|
||||
|
26
bookwyrm/models/favorite.py
Normal file
26
bookwyrm/models/favorite.py
Normal file
@ -0,0 +1,26 @@
|
||||
''' like/fav/star a status '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
class Favorite(ActivitypubMixin, BookWyrmModel):
|
||||
''' fav'ing a post '''
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='actor')
|
||||
status = fields.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, activitypub_field='object')
|
||||
|
||||
activity_serializer = activitypub.Like
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
''' can't fav things twice '''
|
||||
unique_together = ('user', 'status')
|
@ -1,10 +1,10 @@
|
||||
''' activitypub-aware django model fields '''
|
||||
from dataclasses import MISSING
|
||||
import re
|
||||
from uuid import uuid4
|
||||
|
||||
import dateutil.parser
|
||||
from dateutil.parser import ParserError
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.postgres.fields import ArrayField as DjangoArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.base import ContentFile
|
||||
@ -12,8 +12,9 @@ from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.connectors import get_image
|
||||
from bookwyrm.sanitize_html import InputHtmlParser
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
def validate_remote_id(value):
|
||||
@ -25,6 +26,24 @@ def validate_remote_id(value):
|
||||
)
|
||||
|
||||
|
||||
def validate_localname(value):
|
||||
''' make sure localnames look okay '''
|
||||
if not re.match(r'^[A-Za-z\-_\.0-9]+$', value):
|
||||
raise ValidationError(
|
||||
_('%(value)s is not a valid username'),
|
||||
params={'value': value},
|
||||
)
|
||||
|
||||
|
||||
def validate_username(value):
|
||||
''' make sure usernames look okay '''
|
||||
if not re.match(r'^[A-Za-z\-_\.0-9]+@[A-Za-z\-_\.0-9]+\.[a-z]{2,}$', value):
|
||||
raise ValidationError(
|
||||
_('%(value)s is not a valid username'),
|
||||
params={'value': value},
|
||||
)
|
||||
|
||||
|
||||
class ActivitypubFieldMixin:
|
||||
''' make a database field serializable '''
|
||||
def __init__(self, *args, \
|
||||
@ -38,6 +57,39 @@ class ActivitypubFieldMixin:
|
||||
self.activitypub_field = activitypub_field
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
''' helper function for assinging a value to the field '''
|
||||
try:
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
except AttributeError:
|
||||
# masssively hack-y workaround for boosts
|
||||
if self.get_activitypub_field() != 'attributedTo':
|
||||
raise
|
||||
value = getattr(data, 'actor')
|
||||
formatted = self.field_from_activity(value)
|
||||
if formatted is None or formatted is MISSING:
|
||||
return
|
||||
setattr(instance, self.name, formatted)
|
||||
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
''' update the json object '''
|
||||
value = getattr(instance, self.name)
|
||||
formatted = self.field_to_activity(value)
|
||||
if formatted is None:
|
||||
return
|
||||
|
||||
key = self.get_activitypub_field()
|
||||
# TODO: surely there's a better way
|
||||
if instance.__class__.__name__ == 'Boost' and key == 'attributedTo':
|
||||
key = 'actor'
|
||||
if isinstance(activity.get(key), list):
|
||||
activity[key] += formatted
|
||||
else:
|
||||
activity[key] = formatted
|
||||
|
||||
|
||||
def field_to_activity(self, value):
|
||||
''' formatter to convert a model value into activitypub '''
|
||||
if hasattr(self, 'activitypub_wrapper'):
|
||||
@ -61,12 +113,19 @@ class ActivitypubFieldMixin:
|
||||
|
||||
class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
||||
''' default (de)serialization for foreign key and one to one '''
|
||||
def __init__(self, *args, load_remote=True, **kwargs):
|
||||
self.load_remote = load_remote
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def field_from_activity(self, value):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
related_model = self.related_model
|
||||
if isinstance(value, dict) and value.get('id'):
|
||||
if not self.load_remote:
|
||||
# only look in the local database
|
||||
return related_model.find_existing(value)
|
||||
# this is an activitypub object, which we can deserialize
|
||||
activity_serializer = related_model.activity_serializer
|
||||
return activity_serializer(**value).to_model(related_model)
|
||||
@ -77,6 +136,9 @@ class ActivitypubRelatedFieldMixin(ActivitypubFieldMixin):
|
||||
# we don't know what this is, ignore it
|
||||
return None
|
||||
# gets or creates the model field from the remote id
|
||||
if not self.load_remote:
|
||||
# only look in the local database
|
||||
return related_model.find_existing_by_remote_id(value)
|
||||
return activitypub.resolve_remote_id(related_model, value)
|
||||
|
||||
|
||||
@ -94,7 +156,7 @@ class RemoteIdField(ActivitypubFieldMixin, models.CharField):
|
||||
|
||||
class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||
''' activitypub-aware username field '''
|
||||
def __init__(self, activitypub_field='preferredUsername'):
|
||||
def __init__(self, activitypub_field='preferredUsername', **kwargs):
|
||||
self.activitypub_field = activitypub_field
|
||||
# I don't totally know why pylint is mad at this, but it makes it work
|
||||
super( #pylint: disable=bad-super-call
|
||||
@ -103,7 +165,7 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||
_('username'),
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[AbstractUser.username_validator],
|
||||
validators=[validate_username],
|
||||
error_messages={
|
||||
'unique': _('A user with that username already exists.'),
|
||||
},
|
||||
@ -123,6 +185,52 @@ class UsernameField(ActivitypubFieldMixin, models.CharField):
|
||||
return value.split('@')[0]
|
||||
|
||||
|
||||
PrivacyLevels = models.TextChoices('Privacy', [
|
||||
'public',
|
||||
'unlisted',
|
||||
'followers',
|
||||
'direct'
|
||||
])
|
||||
|
||||
class PrivacyField(ActivitypubFieldMixin, models.CharField):
|
||||
''' this maps to two differente activitypub fields '''
|
||||
public = 'https://www.w3.org/ns/activitystreams#Public'
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(
|
||||
*args, max_length=255,
|
||||
choices=PrivacyLevels.choices, default='public')
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
to = data.to
|
||||
cc = data.cc
|
||||
if to == [self.public]:
|
||||
setattr(instance, self.name, 'public')
|
||||
elif cc == []:
|
||||
setattr(instance, self.name, 'direct')
|
||||
elif self.public in cc:
|
||||
setattr(instance, self.name, 'unlisted')
|
||||
else:
|
||||
setattr(instance, self.name, 'followers')
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
mentions = [u.remote_id for u in instance.mention_users.all()]
|
||||
# this is a link to the followers list
|
||||
followers = instance.user.__class__._meta.get_field('followers')\
|
||||
.field_to_activity(instance.user.followers)
|
||||
if instance.privacy == 'public':
|
||||
activity['to'] = [self.public]
|
||||
activity['cc'] = [followers] + mentions
|
||||
elif instance.privacy == 'unlisted':
|
||||
activity['to'] = [followers]
|
||||
activity['cc'] = [self.public] + mentions
|
||||
elif instance.privacy == 'followers':
|
||||
activity['to'] = [followers]
|
||||
activity['cc'] = mentions
|
||||
if instance.privacy == 'direct':
|
||||
activity['to'] = mentions
|
||||
activity['cc'] = []
|
||||
|
||||
|
||||
class ForeignKey(ActivitypubRelatedFieldMixin, models.ForeignKey):
|
||||
''' activitypub-aware foreign key field '''
|
||||
def field_to_activity(self, value):
|
||||
@ -145,6 +253,14 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||
self.link_only = link_only
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def set_field_from_activity(self, instance, data):
|
||||
''' 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
|
||||
getattr(instance, self.name).set(formatted)
|
||||
|
||||
def field_to_activity(self, value):
|
||||
if self.link_only:
|
||||
return '%s/%s' % (value.instance.remote_id, self.name)
|
||||
@ -152,6 +268,8 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
||||
|
||||
def field_from_activity(self, value):
|
||||
items = []
|
||||
if value is None or value is MISSING:
|
||||
return []
|
||||
for remote_id in value:
|
||||
try:
|
||||
validate_remote_id(remote_id)
|
||||
@ -189,6 +307,8 @@ class TagField(ManyToManyField):
|
||||
for link_json in value:
|
||||
link = activitypub.Link(**link_json)
|
||||
tag_type = link.type if link.type != 'Mention' else 'Person'
|
||||
if tag_type == 'Book':
|
||||
tag_type = 'Edition'
|
||||
if tag_type != self.related_model.activity_serializer.type:
|
||||
# tags can contain multiple types
|
||||
continue
|
||||
@ -198,20 +318,45 @@ class TagField(ManyToManyField):
|
||||
return items
|
||||
|
||||
|
||||
def image_serializer(value):
|
||||
def image_serializer(value, alt):
|
||||
''' helper for serializing images '''
|
||||
if value and hasattr(value, 'url'):
|
||||
url = value.url
|
||||
else:
|
||||
return None
|
||||
url = 'https://%s%s' % (DOMAIN, url)
|
||||
return activitypub.Image(url=url)
|
||||
return activitypub.Image(url=url, name=alt)
|
||||
|
||||
|
||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||
''' activitypub-aware image field '''
|
||||
def field_to_activity(self, value):
|
||||
return image_serializer(value)
|
||||
def __init__(self, *args, alt_field=None, **kwargs):
|
||||
self.alt_field = alt_field
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def set_field_from_activity(self, instance, data, save=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
|
||||
getattr(instance, self.name).save(*formatted, save=save)
|
||||
|
||||
def set_activity_from_field(self, activity, instance):
|
||||
value = getattr(instance, self.name)
|
||||
if value is None:
|
||||
return
|
||||
alt_text = getattr(instance, self.alt_field)
|
||||
formatted = self.field_to_activity(value, alt_text)
|
||||
|
||||
key = self.get_activitypub_field()
|
||||
activity[key] = formatted
|
||||
|
||||
|
||||
def field_to_activity(self, value, alt=None):
|
||||
return image_serializer(value, alt)
|
||||
|
||||
|
||||
def field_from_activity(self, value):
|
||||
image_slug = value
|
||||
@ -255,6 +400,15 @@ class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
|
||||
except (ParserError, TypeError):
|
||||
return None
|
||||
|
||||
class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||
''' a text field for storing html '''
|
||||
def field_from_activity(self, value):
|
||||
if not value or value == MISSING:
|
||||
return None
|
||||
sanitizer = InputHtmlParser()
|
||||
sanitizer.feed(value)
|
||||
return sanitizer.get_output()
|
||||
|
||||
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
||||
''' activitypub-aware array field '''
|
||||
def field_to_activity(self, value):
|
||||
|
@ -2,14 +2,13 @@
|
||||
import re
|
||||
import dateutil.parser
|
||||
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import books_manager
|
||||
from bookwyrm.connectors import ConnectorException
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from bookwyrm.models import ReadThrough, User, Book
|
||||
from bookwyrm.utils.fields import JSONField
|
||||
from .base_model import PrivacyLevels
|
||||
from .fields import PrivacyLevels
|
||||
|
||||
|
||||
# Mapping goodreads -> bookwyrm shelf titles.
|
||||
@ -72,12 +71,12 @@ class ImportItem(models.Model):
|
||||
|
||||
def get_book_from_isbn(self):
|
||||
''' search by isbn '''
|
||||
search_result = books_manager.first_search_result(
|
||||
search_result = connector_manager.first_search_result(
|
||||
self.isbn, min_confidence=0.999
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return books_manager.get_or_create_book(search_result.key)
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
|
||||
|
||||
@ -87,12 +86,12 @@ class ImportItem(models.Model):
|
||||
self.data['Title'],
|
||||
self.data['Author']
|
||||
)
|
||||
search_result = books_manager.first_search_result(
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.999
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return books_manager.get_or_create_book(search_result.key)
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
|
||||
|
||||
|
33
bookwyrm/models/notification.py
Normal file
33
bookwyrm/models/notification.py
Normal file
@ -0,0 +1,33 @@
|
||||
''' alert a user to activity '''
|
||||
from django.db import models
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
||||
NotificationType = models.TextChoices(
|
||||
'NotificationType',
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
''' you've been tagged, liked, followed, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
related_book = models.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, null=True)
|
||||
related_user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT, null=True, related_name='related_user')
|
||||
related_status = models.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, null=True)
|
||||
related_import = models.ForeignKey(
|
||||
'ImportJob', on_delete=models.PROTECT, null=True)
|
||||
read = models.BooleanField(default=False)
|
||||
notification_type = models.CharField(
|
||||
max_length=255, choices=NotificationType.choices)
|
||||
|
||||
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",
|
||||
)
|
||||
]
|
26
bookwyrm/models/readthrough.py
Normal file
26
bookwyrm/models/readthrough.py
Normal file
@ -0,0 +1,26 @@
|
||||
''' progress in a book '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||
pages_read = models.IntegerField(
|
||||
null=True,
|
||||
blank=True)
|
||||
start_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
finish_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
@ -37,7 +37,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
||||
|
||||
activity_serializer = activitypub.Follow
|
||||
|
||||
def get_remote_id(self, status=None):
|
||||
def get_remote_id(self, status=None):# pylint: disable=arguments-differ
|
||||
''' use shelf identifier in remote_id '''
|
||||
status = status or 'follows'
|
||||
base_path = self.user_subject.remote_id
|
||||
@ -54,7 +54,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
||||
|
||||
|
||||
def to_reject_activity(self):
|
||||
''' generate an Accept for this follow request '''
|
||||
''' generate a Reject for this follow request '''
|
||||
return activitypub.Reject(
|
||||
id=self.get_remote_id(status='rejects'),
|
||||
actor=self.user_object.remote_id,
|
||||
|
@ -3,8 +3,8 @@ import re
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import BookWyrmModel
|
||||
from .base_model import OrderedCollectionMixin, PrivacyLevels
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .base_model import OrderedCollectionMixin
|
||||
from . import fields
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||
privacy = fields.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
choices=PrivacyLevels.choices
|
||||
choices=fields.PrivacyLevels.choices
|
||||
)
|
||||
books = models.ManyToManyField(
|
||||
'Edition',
|
||||
@ -51,7 +51,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
||||
unique_together = ('user', 'identifier')
|
||||
|
||||
|
||||
class ShelfBook(BookWyrmModel):
|
||||
class ShelfBook(ActivitypubMixin, BookWyrmModel):
|
||||
''' many to many join table for books and shelves '''
|
||||
book = fields.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, activitypub_field='object')
|
||||
|
@ -1,12 +1,16 @@
|
||||
''' models for storing different kinds of Activities '''
|
||||
from django.utils import timezone
|
||||
from dataclasses import MISSING
|
||||
import re
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
|
||||
from .base_model import BookWyrmModel, PrivacyLevels
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
from .fields import image_serializer
|
||||
|
||||
@ -14,17 +18,15 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
''' any post, like a reply to a review, etc '''
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='attributedTo')
|
||||
content = fields.TextField(blank=True, null=True)
|
||||
content = fields.HtmlField(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)
|
||||
privacy = models.CharField(
|
||||
max_length=255,
|
||||
default='public',
|
||||
choices=PrivacyLevels.choices
|
||||
)
|
||||
content_warning = fields.CharField(
|
||||
max_length=500, blank=True, null=True, activitypub_field='summary')
|
||||
privacy = fields.PrivacyField(max_length=255)
|
||||
sensitive = fields.BooleanField(default=False)
|
||||
# the created date can't be this, because of receiving federated posts
|
||||
# created date is different than publish date because of federated posts
|
||||
published_date = fields.DateTimeField(
|
||||
default=timezone.now, activitypub_field='published')
|
||||
deleted = models.BooleanField(default=False)
|
||||
@ -48,18 +50,45 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
serialize_reverse_fields = [('attachments', 'attachment')]
|
||||
deserialize_reverse_fields = [('attachments', 'attachment')]
|
||||
|
||||
#----- replies collection activitypub ----#
|
||||
@classmethod
|
||||
def ignore_activity(cls, activity):
|
||||
''' keep notes if they are replies to existing statuses '''
|
||||
if activity.type != 'Note':
|
||||
return False
|
||||
if cls.objects.filter(
|
||||
remote_id=activity.inReplyTo).exists():
|
||||
return False
|
||||
|
||||
# keep notes if they mention local users
|
||||
if activity.tag == MISSING or activity.tag is None:
|
||||
return True
|
||||
tags = [l['href'] for l in activity.tag if l['type'] == 'Mention']
|
||||
for tag in tags:
|
||||
user_model = apps.get_model('bookwyrm.User', require_ready=True)
|
||||
if user_model.objects.filter(
|
||||
remote_id=tag, local=True).exists():
|
||||
# we found a mention of a known use boost
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def replies(cls, status):
|
||||
''' load all replies to a status. idk if there's a better way
|
||||
to write this so it's just a property '''
|
||||
return cls.objects.filter(reply_parent=status).select_subclasses()
|
||||
return cls.objects.filter(
|
||||
reply_parent=status
|
||||
).select_subclasses().order_by('published_date')
|
||||
|
||||
@property
|
||||
def status_type(self):
|
||||
''' expose the type of status for the ui using activity type '''
|
||||
return self.activity_serializer.__name__
|
||||
|
||||
@property
|
||||
def boostable(self):
|
||||
''' you can't boost dms '''
|
||||
return self.privacy in ['unlisted', 'public']
|
||||
|
||||
def to_replies(self, **kwargs):
|
||||
''' helper function for loading AP serialized replies to a status '''
|
||||
return self.to_ordered_collection(
|
||||
@ -68,7 +97,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_activity(self, pure=False):
|
||||
def to_activity(self, pure=False):# pylint: disable=arguments-differ
|
||||
''' return tombstone if the status is deleted '''
|
||||
if self.deleted:
|
||||
return activitypub.Tombstone(
|
||||
@ -80,37 +109,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||
activity = ActivitypubMixin.to_activity(self)
|
||||
activity['replies'] = self.to_replies()
|
||||
|
||||
# privacy controls
|
||||
public = 'https://www.w3.org/ns/activitystreams#Public'
|
||||
mentions = [u.remote_id for u in self.mention_users.all()]
|
||||
# this is a link to the followers list:
|
||||
followers = self.user.__class__._meta.get_field('followers')\
|
||||
.field_to_activity(self.user.followers)
|
||||
if self.privacy == 'public':
|
||||
activity['to'] = [public]
|
||||
activity['cc'] = [followers] + mentions
|
||||
elif self.privacy == 'unlisted':
|
||||
activity['to'] = [followers]
|
||||
activity['cc'] = [public] + mentions
|
||||
elif self.privacy == 'followers':
|
||||
activity['to'] = [followers]
|
||||
activity['cc'] = mentions
|
||||
if self.privacy == 'direct':
|
||||
activity['to'] = mentions
|
||||
activity['cc'] = []
|
||||
|
||||
# "pure" serialization for non-bookwyrm instances
|
||||
if pure:
|
||||
if pure and hasattr(self, 'pure_content'):
|
||||
activity['content'] = self.pure_content
|
||||
if 'name' in activity:
|
||||
activity['name'] = self.pure_name
|
||||
activity['type'] = self.pure_type
|
||||
activity['attachment'] = [
|
||||
image_serializer(b.cover) for b in self.mention_books.all() \
|
||||
if b.cover]
|
||||
if hasattr(self, 'book'):
|
||||
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)
|
||||
image_serializer(self.book.cover, self.book.alt_text)
|
||||
)
|
||||
return activity
|
||||
|
||||
@ -147,8 +157,8 @@ class Comment(Status):
|
||||
@property
|
||||
def pure_content(self):
|
||||
''' indicate the book in question for mastodon (or w/e) users '''
|
||||
return self.content + '<br><br>(comment on <a href="%s">"%s"</a>)' % \
|
||||
(self.book.remote_id, self.book.title)
|
||||
return '%s<p>(comment on <a href="%s">"%s"</a>)</p>' % \
|
||||
(self.content, self.book.remote_id, self.book.title)
|
||||
|
||||
activity_serializer = activitypub.Comment
|
||||
pure_type = 'Note'
|
||||
@ -156,15 +166,17 @@ class Comment(Status):
|
||||
|
||||
class Quotation(Status):
|
||||
''' like a review but without a rating and transient '''
|
||||
quote = fields.TextField()
|
||||
quote = fields.HtmlField()
|
||||
book = fields.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, activitypub_field='inReplyToBook')
|
||||
|
||||
@property
|
||||
def pure_content(self):
|
||||
''' indicate the book in question for mastodon (or w/e) users '''
|
||||
return '"%s"<br>-- <a href="%s">"%s"</a><br><br>%s' % (
|
||||
self.quote,
|
||||
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,
|
||||
@ -190,6 +202,7 @@ class Review(Status):
|
||||
def pure_name(self):
|
||||
''' clarify review names for mastodon serialization '''
|
||||
if self.rating:
|
||||
#pylint: disable=bad-string-format-type
|
||||
return 'Review of "%s" (%d stars): %s' % (
|
||||
self.book.title,
|
||||
self.rating,
|
||||
@ -203,33 +216,12 @@ class Review(Status):
|
||||
@property
|
||||
def pure_content(self):
|
||||
''' indicate the book in question for mastodon (or w/e) users '''
|
||||
return self.content + '<br><br>(<a href="%s">"%s"</a>)' % \
|
||||
(self.book.remote_id, self.book.title)
|
||||
return self.content
|
||||
|
||||
activity_serializer = activitypub.Review
|
||||
pure_type = 'Article'
|
||||
|
||||
|
||||
class Favorite(ActivitypubMixin, BookWyrmModel):
|
||||
''' fav'ing a post '''
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='actor')
|
||||
status = fields.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, activitypub_field='object')
|
||||
|
||||
activity_serializer = activitypub.Like
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
''' can't fav things twice '''
|
||||
unique_together = ('user', 'status')
|
||||
|
||||
|
||||
class Boost(Status):
|
||||
''' boost'ing a post '''
|
||||
boosted_status = fields.ForeignKey(
|
||||
@ -239,59 +231,20 @@ class Boost(Status):
|
||||
activitypub_field='object',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
''' the user field is "actor" here instead of "attributedTo" '''
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
reserve_fields = ['user', 'boosted_status']
|
||||
self.simple_fields = [f for f in self.simple_fields if \
|
||||
f.name in reserve_fields]
|
||||
self.activity_fields = self.simple_fields
|
||||
self.many_to_many_fields = []
|
||||
self.image_fields = []
|
||||
self.deserialize_reverse_fields = []
|
||||
|
||||
activity_serializer = activitypub.Boost
|
||||
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
book = models.ForeignKey('Book', on_delete=models.PROTECT)
|
||||
pages_read = models.IntegerField(
|
||||
null=True,
|
||||
blank=True)
|
||||
start_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
finish_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
NotificationType = models.TextChoices(
|
||||
'NotificationType',
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
''' you've been tagged, liked, followed, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
related_book = models.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, null=True)
|
||||
related_user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT, null=True, related_name='related_user')
|
||||
related_status = models.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, null=True)
|
||||
related_import = models.ForeignKey(
|
||||
'ImportJob', on_delete=models.PROTECT, null=True)
|
||||
read = models.BooleanField(default=False)
|
||||
notification_type = models.CharField(
|
||||
max_length=255, choices=NotificationType.choices)
|
||||
|
||||
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",
|
||||
)
|
||||
]
|
||||
|
@ -17,7 +17,9 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
|
||||
@classmethod
|
||||
def book_queryset(cls, identifier):
|
||||
''' county of books associated with this tag '''
|
||||
return cls.objects.filter(identifier=identifier)
|
||||
return cls.objects.filter(
|
||||
identifier=identifier
|
||||
).order_by('-updated_date')
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
@ -64,7 +66,7 @@ class UserTag(BookWyrmModel):
|
||||
id='%s#remove' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.to_activity(),
|
||||
target=self.remote_id,
|
||||
).serialize()
|
||||
|
||||
|
||||
|
@ -1,6 +1,8 @@
|
||||
''' database schema for user data '''
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
@ -12,6 +14,7 @@ from bookwyrm.models.status import Status, Review
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.signatures import create_key_pair
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.utils import regex
|
||||
from .base_model import OrderedCollectionPageMixin
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from .federated_server import FederatedServer
|
||||
@ -42,18 +45,20 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
blank=True,
|
||||
)
|
||||
outbox = fields.RemoteIdField(unique=True)
|
||||
summary = fields.TextField(default='')
|
||||
summary = fields.HtmlField(null=True, blank=True)
|
||||
local = models.BooleanField(default=False)
|
||||
bookwyrm_user = fields.BooleanField(default=True)
|
||||
localname = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True
|
||||
unique=True,
|
||||
validators=[fields.validate_localname],
|
||||
)
|
||||
# name is your display name, which you can change at will
|
||||
name = fields.CharField(max_length=100, default='')
|
||||
name = fields.CharField(max_length=100, null=True, blank=True)
|
||||
avatar = fields.ImageField(
|
||||
upload_to='avatars/', blank=True, null=True, activitypub_field='icon')
|
||||
upload_to='avatars/', blank=True, null=True,
|
||||
activitypub_field='icon', alt_field='alt_text')
|
||||
followers = fields.ManyToManyField(
|
||||
'self',
|
||||
link_only=True,
|
||||
@ -90,20 +95,37 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
last_active_date = models.DateTimeField(auto_now=True)
|
||||
manually_approves_followers = fields.BooleanField(default=False)
|
||||
|
||||
name_field = 'username'
|
||||
@property
|
||||
def alt_text(self):
|
||||
''' alt text with username '''
|
||||
return 'avatar for %s' % (self.localname or self.username)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
''' show the cleanest version of the user's name possible '''
|
||||
if self.name != '':
|
||||
if self.name and self.name != '':
|
||||
return self.name
|
||||
return self.localname or self.username
|
||||
|
||||
activity_serializer = activitypub.Person
|
||||
|
||||
def to_outbox(self, **kwargs):
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
''' an ordered collection of statuses '''
|
||||
queryset = Status.objects.filter(
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
'bookwyrm.%s' % filter_type, require_ready=True)
|
||||
if not issubclass(filter_class, Status):
|
||||
raise TypeError(
|
||||
'filter_status_class must be a subclass of models.Status')
|
||||
queryset = filter_class.objects
|
||||
else:
|
||||
queryset = Status.objects
|
||||
|
||||
queryset = queryset.filter(
|
||||
user=self,
|
||||
deleted=False,
|
||||
privacy__in=['public', 'unlisted'],
|
||||
).select_subclasses().order_by('-published_date')
|
||||
return self.to_ordered_collection(queryset, \
|
||||
remote_id=self.outbox, **kwargs)
|
||||
@ -111,14 +133,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
def to_following_activity(self, **kwargs):
|
||||
''' activitypub following list '''
|
||||
remote_id = '%s/following' % self.remote_id
|
||||
return self.to_ordered_collection(self.following.all(), \
|
||||
remote_id=remote_id, id_only=True, **kwargs)
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by('-updated_date').all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_followers_activity(self, **kwargs):
|
||||
''' activitypub followers list '''
|
||||
remote_id = '%s/followers' % self.remote_id
|
||||
return self.to_ordered_collection(self.followers.all(), \
|
||||
remote_id=remote_id, id_only=True, **kwargs)
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by('-updated_date').all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_activity(self):
|
||||
''' override default AP serializer to add context object
|
||||
@ -140,26 +170,28 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
def save(self, *args, **kwargs):
|
||||
''' populate fields for new local users '''
|
||||
# this user already exists, no need to populate fields
|
||||
if self.id:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
if not self.local:
|
||||
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)
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
if self.id or not self.local:
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
# populate fields for local users
|
||||
self.remote_id = 'https://%s/user/%s' % (DOMAIN, self.username)
|
||||
self.localname = self.username
|
||||
self.username = '%s@%s' % (self.username, DOMAIN)
|
||||
self.actor = self.remote_id
|
||||
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
|
||||
|
||||
return 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 '''
|
||||
@ -265,7 +297,7 @@ def get_or_create_remote_server(domain):
|
||||
@app.task
|
||||
def get_remote_reviews(outbox):
|
||||
''' ingest reviews by a new remote bookwyrm user '''
|
||||
outbox_page = outbox + '?page=true'
|
||||
outbox_page = outbox + '?page=true&type=Review'
|
||||
data = get_data(outbox_page)
|
||||
|
||||
# TODO: pagination?
|
||||
|
Reference in New Issue
Block a user