Merge pull request #362 from mouse-reeve/display-images

Display images
This commit is contained in:
Mouse Reeve 2020-11-24 13:40:07 -08:00 committed by GitHub
commit 7ff8078be2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 181 additions and 76 deletions

View file

@ -4,7 +4,9 @@ import sys
from .base_activity import ActivityEncoder, Image, PublicKey, Signature from .base_activity import ActivityEncoder, Image, PublicKey, Signature
from .base_activity import Link, Mention from .base_activity import Link, Mention
from .base_activity import ActivitySerializerError, tag_formatter from .base_activity import ActivitySerializerError
from .base_activity import tag_formatter
from .base_activity import image_formatter, image_attachments_formatter
from .note import Note, GeneratedNote, Article, Comment, Review, Quotation from .note import Note, GeneratedNote, Article, Comment, Review, Quotation
from .note import Tombstone from .note import Tombstone
from .interaction import Boost, Like from .interaction import Boost, Like

View file

@ -1,12 +1,17 @@
''' basics for an activitypub serializer ''' ''' basics for an activitypub serializer '''
from dataclasses import dataclass, fields, MISSING from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder from json import JSONEncoder
from uuid import uuid4
from django.core.files.base import ContentFile
from django.db.models.fields.related_descriptors \
import ForwardManyToOneDescriptor, ManyToManyDescriptor, \
ReverseManyToOneDescriptor
from django.db.models.fields.files import ImageFileDescriptor
import requests
from bookwyrm import books_manager, models from bookwyrm import books_manager, models
from django.db.models.fields.related_descriptors \
import ForwardManyToOneDescriptor, ManyToManyDescriptor
class ActivitySerializerError(ValueError): class ActivitySerializerError(ValueError):
''' routine problems serializing activitypub json ''' ''' routine problems serializing activitypub json '''
@ -91,6 +96,8 @@ class ActivityObject:
model_fields = [m.name for m in model._meta.get_fields()] model_fields = [m.name for m in model._meta.get_fields()]
mapped_fields = {} mapped_fields = {}
many_to_many_fields = {} many_to_many_fields = {}
one_to_many_fields = {}
image_fields = {}
for mapping in model.activity_mappings: for mapping in model.activity_mappings:
if mapping.model_key not in model_fields: if mapping.model_key not in model_fields:
@ -102,20 +109,26 @@ class ActivityObject:
value = getattr(self, mapping.activity_key) value = getattr(self, mapping.activity_key)
model_field = getattr(model, mapping.model_key) model_field = getattr(model, mapping.model_key)
# remote_id -> foreign key resolver
if isinstance(model_field, ForwardManyToOneDescriptor) and value:
fk_model = model_field.field.related_model
value = resolve_foreign_key(fk_model, value)
formatted_value = mapping.model_formatter(value) formatted_value = mapping.model_formatter(value)
if isinstance(model_field, ManyToManyDescriptor): if isinstance(model_field, ForwardManyToOneDescriptor) and \
formatted_value:
# foreign key remote id reolver
fk_model = model_field.field.related_model
reference = resolve_foreign_key(fk_model, formatted_value)
mapped_fields[mapping.model_key] = reference
elif isinstance(model_field, ManyToManyDescriptor):
many_to_many_fields[mapping.model_key] = formatted_value many_to_many_fields[mapping.model_key] = formatted_value
elif isinstance(model_field, ReverseManyToOneDescriptor):
# attachments on statuses, for example
one_to_many_fields[mapping.model_key] = formatted_value
elif isinstance(model_field, ImageFileDescriptor):
# image fields need custom handling
image_fields[mapping.model_key] = formatted_value
else: else:
mapped_fields[mapping.model_key] = formatted_value mapped_fields[mapping.model_key] = formatted_value
# updating an existing model isntance
if instance: if instance:
# updating an existing model isntance
for k, v in mapped_fields.items(): for k, v in mapped_fields.items():
setattr(instance, k, v) setattr(instance, k, v)
instance.save() instance.save()
@ -123,9 +136,26 @@ class ActivityObject:
# creating a new model instance # creating a new model instance
instance = model.objects.create(**mapped_fields) instance = model.objects.create(**mapped_fields)
# add many-to-many fields
for (model_key, values) in many_to_many_fields.items(): for (model_key, values) in many_to_many_fields.items():
getattr(instance, model_key).set(values) getattr(instance, model_key).set(values)
instance.save() instance.save()
# add images
for (model_key, value) in image_fields.items():
getattr(instance, model_key).save(*value, save=True)
# add one to many fields
for (model_key, values) in one_to_many_fields.items():
items = []
for item in values:
# the reference id wasn't available at creation time
setattr(item, instance.__class__.__name__.lower(), instance)
item.save()
items.append(item)
if items:
getattr(instance, model_key).set(items)
instance.save()
return instance return instance
@ -156,15 +186,14 @@ def resolve_foreign_key(model, remote_id):
return result return result
def tag_formatter(tags): def tag_formatter(tags, tag_type):
''' helper function to extract foreign keys from tag activity json ''' ''' helper function to extract foreign keys from tag activity json '''
items = [] items = []
types = { types = {
'Book': models.Book, 'Book': models.Book,
'Mention': models.User, 'Mention': models.User,
} }
for tag in tags: for tag in [t for t in tags if t.get('type') == tag_type]:
tag_type = tag.get('type')
if not tag_type in types: if not tag_type in types:
continue continue
remote_id = tag.get('href') remote_id = tag.get('href')
@ -174,3 +203,33 @@ def tag_formatter(tags):
continue continue
items.append(item) items.append(item)
return items return items
def image_formatter(image_json):
''' helper function to load images and format them for a model '''
url = image_json.get('url')
if not url:
return None
try:
response = requests.get(url)
except ConnectionError:
return None
if not response.ok:
return None
image_name = str(uuid4()) + '.' + url.split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
def image_attachments_formatter(images_json):
''' deserialize a list of images '''
attachments = []
for image in images_json:
caption = image.get('name')
attachment = models.Attachment(caption=caption)
image_field = image_formatter(image)
attachment.image.save(*image_field, save=False)
attachments.append(attachment)
return attachments

View file

@ -0,0 +1,29 @@
# Generated by Django 3.0.7 on 2020-11-24 19:39
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('bookwyrm', '0011_auto_20201113_1727'),
]
operations = [
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('remote_id', models.CharField(max_length=255, null=True)),
('image', models.ImageField(blank=True, null=True, upload_to='status/')),
('caption', models.TextField(blank=True, null=True)),
('status', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='bookwyrm.Status')),
],
options={
'abstract': False,
},
),
]

View file

@ -7,7 +7,7 @@ from .connector import Connector
from .relationship import UserFollows, UserFollowRequest, UserBlocks from .relationship import UserFollows, UserFollowRequest, UserBlocks
from .shelf import Shelf, ShelfBook from .shelf import Shelf, ShelfBook
from .status import Status, GeneratedNote, Review, Comment, Quotation from .status import Status, GeneratedNote, Review, Comment, Quotation
from .status import Favorite, Boost, Notification, ReadThrough from .status import Attachment, Favorite, Boost, Notification, ReadThrough
from .tag import Tag from .tag import Tag
from .user import User from .user import User
from .federated_server import FederatedServer from .federated_server import FederatedServer

View file

@ -261,3 +261,20 @@ def tag_formatter(items, name_field, activity_type):
type=activity_type type=activity_type
)) ))
return tags return tags
def image_formatter(image, default_path=None):
''' convert images into activitypub json '''
if image:
url = image.url
elif default_path:
url = default_path
else:
return None
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
def image_attachments_formatter(images):
''' create a list of image attachments '''
return [image_formatter(i) for i in images]

View file

@ -12,6 +12,7 @@ from bookwyrm.utils.fields import ArrayField
from .base_model import ActivityMapping, BookWyrmModel from .base_model import ActivityMapping, BookWyrmModel
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import image_attachments_formatter
class Book(ActivitypubMixin, BookWyrmModel): class Book(ActivitypubMixin, BookWyrmModel):
''' a generic book, which can mean either an edition or a work ''' ''' a generic book, which can mean either an edition or a work '''
@ -60,15 +61,6 @@ class Book(ActivitypubMixin, BookWyrmModel):
''' the activitypub serialization should be a list of author ids ''' ''' the activitypub serialization should be a list of author ids '''
return [a.remote_id for a in self.authors.all()] return [a.remote_id for a in self.authors.all()]
@property
def ap_cover(self):
''' an image attachment '''
if not self.cover or not hasattr(self.cover, 'url'):
return []
return [activitypub.Image(
url='https://%s%s' % (DOMAIN, self.cover.url),
)]
@property @property
def ap_parent_work(self): def ap_parent_work(self):
''' reference the work via local id not remote ''' ''' reference the work via local id not remote '''
@ -106,7 +98,12 @@ class Book(ActivitypubMixin, BookWyrmModel):
ActivityMapping('lccn', 'lccn'), ActivityMapping('lccn', 'lccn'),
ActivityMapping('editions', 'editions_path'), ActivityMapping('editions', 'editions_path'),
ActivityMapping('attachment', 'ap_cover'), ActivityMapping(
'attachment', 'cover',
# this expects an iterable and the field is just an image
lambda x: image_attachments_formatter([x]),
lambda x: activitypub.image_attachments_formatter(x)[0]
),
] ]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View file

@ -7,7 +7,7 @@ from model_utils.managers import InheritanceManager
from bookwyrm import activitypub from bookwyrm import activitypub
from .base_model import ActivitypubMixin, OrderedCollectionPageMixin from .base_model import ActivitypubMixin, OrderedCollectionPageMixin
from .base_model import ActivityMapping, BookWyrmModel, PrivacyLevels from .base_model import ActivityMapping, BookWyrmModel, PrivacyLevels
from .base_model import tag_formatter from .base_model import tag_formatter, image_attachments_formatter
class Status(OrderedCollectionPageMixin, BookWyrmModel): class Status(OrderedCollectionPageMixin, BookWyrmModel):
@ -80,13 +80,18 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
ActivityMapping( ActivityMapping(
'tag', 'mention_books', 'tag', 'mention_books',
lambda x: tag_formatter(x, 'title', 'Book'), lambda x: tag_formatter(x, 'title', 'Book'),
activitypub.tag_formatter lambda x: activitypub.tag_formatter(x, 'Book')
), ),
ActivityMapping( ActivityMapping(
'tag', 'mention_users', 'tag', 'mention_users',
lambda x: tag_formatter(x, 'username', 'Mention'), lambda x: tag_formatter(x, 'username', 'Mention'),
activitypub.tag_formatter lambda x: activitypub.tag_formatter(x, 'Mention')
), ),
ActivityMapping(
'attachment', 'attachments',
lambda x: image_attachments_formatter(x.all()),
activitypub.image_attachments_formatter
)
] ]
# serializing to bookwyrm expanded activitypub # serializing to bookwyrm expanded activitypub
@ -140,9 +145,21 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
''' update user active time ''' ''' update user active time '''
self.user.last_active_date = timezone.now() if self.user.local:
self.user.save() self.user.last_active_date = timezone.now()
super().save(*args, **kwargs) self.user.save()
return super().save(*args, **kwargs)
class Attachment(BookWyrmModel):
''' an image (or, in the future, video etc) associated with a status '''
status = models.ForeignKey(
'Status',
on_delete=models.CASCADE,
related_name='attachments'
)
image = models.ImageField(upload_to='status/', null=True, blank=True)
caption = models.TextField(null=True, blank=True)
class GeneratedNote(Status): class GeneratedNote(Status):

View file

@ -10,8 +10,8 @@ from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status from bookwyrm.models.status import Status
from bookwyrm.settings import DOMAIN from bookwyrm.settings import DOMAIN
from bookwyrm.signatures import create_key_pair from bookwyrm.signatures import create_key_pair
from .base_model import OrderedCollectionPageMixin from .base_model import ActivityMapping, OrderedCollectionPageMixin
from .base_model import ActivityMapping from .base_model import image_formatter
class User(OrderedCollectionPageMixin, AbstractUser): class User(OrderedCollectionPageMixin, AbstractUser):
@ -78,16 +78,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
''' generates url for activitypub followers page ''' ''' generates url for activitypub followers page '''
return '%s/followers' % self.remote_id return '%s/followers' % self.remote_id
@property
def ap_icon(self):
''' send default icon if one isn't set '''
if self.avatar:
url = self.avatar.url
else:
url = '/static/images/default_avi.jpg'
url = 'https://%s%s' % (DOMAIN, url)
return activitypub.Image(url=url)
@property @property
def ap_public_key(self): def ap_public_key(self):
''' format the public key block for activitypub ''' ''' format the public key block for activitypub '''
@ -122,7 +112,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activity_formatter=lambda x: {'sharedInbox': x}, activity_formatter=lambda x: {'sharedInbox': x},
model_formatter=lambda x: x.get('sharedInbox') model_formatter=lambda x: x.get('sharedInbox')
), ),
ActivityMapping('icon', 'ap_icon'), ActivityMapping(
'icon', 'avatar',
lambda x: image_formatter(x, '/static/images/default_avi.jpg'),
activitypub.image_formatter
),
ActivityMapping( ActivityMapping(
'manuallyApprovesFollowers', 'manuallyApprovesFollowers',
'manually_approves_followers' 'manually_approves_followers'

View file

@ -1,9 +1,7 @@
''' manage remote users ''' ''' manage remote users '''
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import uuid4
import requests import requests
from django.core.files.base import ContentFile
from django.db import transaction from django.db import transaction
from bookwyrm import activitypub, models from bookwyrm import activitypub, models
@ -22,14 +20,9 @@ def get_or_create_remote_user(actor):
actor_parts = urlparse(actor) actor_parts = urlparse(actor)
with transaction.atomic(): with transaction.atomic():
user = create_remote_user(data) user = activitypub.Person(**data).to_model(models.User)
user.federated_server = get_or_create_remote_server(actor_parts.netloc) user.federated_server = get_or_create_remote_server(actor_parts.netloc)
user.save() user.save()
avatar = get_avatar(data)
if avatar:
user.avatar.save(*avatar)
if user.bookwyrm_user: if user.bookwyrm_user:
get_remote_reviews.delay(user.id) get_remote_reviews.delay(user.id)
return user return user
@ -55,12 +48,6 @@ def fetch_user_data(actor):
return data return data
def create_remote_user(data):
''' parse the activitypub actor data into a user '''
actor = activitypub.Person(**data)
return actor.to_model(models.User)
def refresh_remote_user(user): def refresh_remote_user(user):
''' get updated user data from its home instance ''' ''' get updated user data from its home instance '''
data = fetch_user_data(user.remote_id) data = fetch_user_data(user.remote_id)
@ -69,21 +56,6 @@ def refresh_remote_user(user):
activity.to_model(models.User, instance=user) activity.to_model(models.User, instance=user)
def get_avatar(data):
''' find the icon attachment and load the image from the remote sever '''
icon_blob = data.get('icon')
if not icon_blob or not icon_blob.get('url'):
return None
response = requests.get(icon_blob['url'])
if not response.ok:
return None
image_name = str(uuid4()) + '.' + icon_blob['url'].split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
@app.task @app.task
def get_remote_reviews(user_id): def get_remote_reviews(user_id):
''' ingest reviews by a new remote bookwyrm user ''' ''' ingest reviews by a new remote bookwyrm user '''

View file

@ -1,4 +1,7 @@
/* --- --- */ /* --- --- */
.image {
overflow: hidden;
}
.navbar .logo { .navbar .logo {
max-height: 50px; max-height: 50px;
} }

View file

@ -13,7 +13,7 @@ def delete_status(status):
def create_status(activity): def create_status(activity):
''' unfortunately, it's not QUITE as simple as deserialiing it ''' ''' unfortunately, it's not QUITE as simple as deserializing it '''
# render the json into an activity object # render the json into an activity object
serializer = activitypub.activity_objects[activity['type']] serializer = activitypub.activity_objects[activity['type']]
activity = serializer(**activity) activity = serializer(**activity)

View file

@ -18,6 +18,21 @@
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %} {% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Boost' %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe %} {% include 'snippets/trimmed_text.html' with full=status.content|safe %}
{% endif %} {% endif %}
{% if status.attachments %}
<div class="block">
<div class="columns">
{% for attachment in status.attachments.all %}
<div class="column is-narrow">
<figure class="image is-128x128">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="open image in new window">
<img src="/images/{{ attachment.image }}" alt="{{ attachment.caption }}">
</a>
</figure>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
{% if not hide_book %} {% if not hide_book %}

2
bw-dev
View file

@ -61,7 +61,7 @@ case "$1" in
;; ;;
migrate) migrate)
execweb python manage.py rename_app fedireads bookwyrm execweb python manage.py rename_app fedireads bookwyrm
execweb python manage.py migrate execweb python manage.py "$@"
;; ;;
bash) bash)
execweb bash execweb bash