Merge pull request #11 from mouse-reeve/stop-storing-ap-json

Stop storing ap json
This commit is contained in:
Mouse Reeve 2020-02-18 17:32:09 -08:00 committed by GitHub
commit 978545717e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 630 additions and 476 deletions

View file

@ -0,0 +1,7 @@
''' bring activitypub functions into the namespace '''
from .actor import get_actor
from .collection import get_outbox, get_outbox_page, get_add, get_remove, \
get_following, get_followers
from .create import get_create
from .follow import get_follow_request, get_accept
from .status import get_review, get_review_article, get_status, get_replies

View file

@ -0,0 +1,28 @@
''' actor serializer '''
def get_actor(user):
''' activitypub actor from db User '''
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1'
],
'id': user.actor,
'type': 'Person',
'preferredUsername': user.localname,
'name': user.name,
'inbox': user.inbox,
'followers': '%s/followers' % user.actor,
'following': '%s/following' % user.actor,
'summary': user.summary,
'publicKey': {
'id': '%s/#main-key' % user.actor,
'owner': user.actor,
'publicKeyPem': user.public_key,
},
'endpoints': {
'sharedInbox': user.shared_inbox,
}
}

View file

@ -0,0 +1,128 @@
''' activitypub json for collections '''
from uuid import uuid4
from urllib.parse import urlencode
from .status import get_status
def get_outbox(user, size):
''' helper function for creating an outbox '''
return get_ordered_collection(user.outbox, size)
def get_outbox_page(user, page_id, statuses, max_id, min_id):
''' helper for formatting outbox pages '''
# not generalizing this more because the format varies for some reason
page = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': page_id,
'type': 'OrderedCollectionPage',
'partOf': user.outbox,
'orderedItems': [],
}
for status in statuses:
page['orderedItems'].append(get_status(status))
if max_id:
page['next'] = user.outbox + '?' + \
urlencode({'min_id': max_id, 'page': 'true'})
if min_id:
page['prev'] = user.outbox + '?' + \
urlencode({'max_id': min_id, 'page': 'true'})
return page
def get_followers(user, page, follow_queryset):
''' list of people who follow a user '''
id_slug = '%s/followers' % user.actor
return get_follow_info(id_slug, page, follow_queryset)
def get_following(user, page, follow_queryset):
''' list of people who follow a user '''
id_slug = '%s/following' % user.actor
return get_follow_info(id_slug, page, follow_queryset)
def get_follow_info(id_slug, page, follow_queryset):
''' a list of followers or following '''
if page:
return get_follow_page(follow_queryset, id_slug, page)
count = follow_queryset.count()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'OrderedCollection',
'totalItems': count,
'first': '%s?page=1' % id_slug,
}
# TODO: generalize these pagination functions
def get_follow_page(user_list, id_slug, page):
''' format a list of followers/following '''
page = int(page)
page_length = 10
start = (page - 1) * page_length
end = start + page_length
follower_page = user_list.all()[start:end]
data = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s?page=%d' % (id_slug, page),
'type': 'OrderedCollectionPage',
'totalItems': user_list.count(),
'partOf': id_slug,
'orderedItems': [u.actor for u in follower_page],
}
if end <= user_list.count():
# there are still more pages
data['next'] = '%s?page=%d' % (id_slug, page + 1)
if start > 0:
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
return data
def get_ordered_collection(id_slug, size):
''' create an ordered collection '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'OrderedCollection',
'totalItems': size,
'first': '%s?page=true' % id_slug,
'last': '%s?min_id=0&page=true' % id_slug
}
def get_add(*args):
''' activitypub Add activity '''
return get_add_remove(*args, action='Add')
def get_remove(*args):
''' activitypub Add activity '''
return get_add_remove(*args, action='Remove')
def get_add_remove(user, book, shelf, action='Add'):
''' format an Add or Remove json blob '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'type': action,
'actor': user.actor,
'object': {
'type': 'Document',
'name': book.data['title'],
'url': book.openlibrary_key
},
'target': {
'type': 'Collection',
'name': shelf.name,
'id': shelf.absolute_id,
}
}

View file

@ -0,0 +1,33 @@
''' format Create activities and sign them '''
from base64 import b64encode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
def get_create(user, status_json):
''' create activitypub json for a Create activity '''
signer = pkcs1_15.new(RSA.import_key(user.private_key))
content = status_json['content']
signed_message = signer.sign(SHA256.new(content.encode('utf8')))
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/activity' % status_json['id'],
'type': 'Create',
'actor': user.actor,
'published': status_json['published'],
'to': ['%s/followers' % user.actor],
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': status_json,
'signature': {
'type': 'RsaSignature2017',
'creator': '%s#main-key' % user.absolute_id,
'created': status_json['published'],
'signatureValue': b64encode(signed_message).decode('utf8'),
}
}

View file

@ -0,0 +1,29 @@
''' makin' freinds inthe ap json format '''
from uuid import uuid4
from fedireads.settings import DOMAIN
def get_follow_request(user, to_follow):
''' a local user wants to follow someone '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://%s/%s' % (DOMAIN, str(uuid)),
'summary': '',
'type': 'Follow',
'actor': user.actor,
'object': to_follow.actor,
}
def get_accept(user, request_activity):
''' accept a follow request '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s#accepts/follows/' % user.absolute_id,
'type': 'Accept',
'actor': user.actor,
'object': request_activity,
}

View file

@ -0,0 +1,73 @@
''' status serializers '''
def get_review(review):
''' fedireads json for book reviews '''
status = get_status(review)
status['inReplyToBook'] = review.book.absolute_id
status['fedireadsType'] = review.status_type,
status['name'] = review.name
status['rating'] = review.rating
return status
def get_review_article(review):
''' a book review formatted for a non-fedireads isntance (mastodon) '''
status = get_status(review)
name = 'Review of "%s" (%d stars): %s' % (
review.book.data['title'],
review.rating,
review.name
)
status['name'] = name
return status
def get_status(status):
''' create activitypub json for a status '''
user = status.user
uri = status.absolute_id
reply_parent_id = status.reply_parent.absolute_id \
if status.reply_parent else None
status_json = {
'id': uri,
'url': uri,
'inReplyTo': reply_parent_id,
'published': status.created_date.isoformat(),
'attributedTo': user.actor,
# TODO: assuming all posts are public -- should check privacy db field
'to': ['https://www.w3.org/ns/activitystreams#Public'],
'cc': ['%s/followers' % user.absolute_id],
'sensitive': status.sensitive,
'content': status.content,
'type': status.activity_type,
'attachment': [], # TODO: the book cover
'replies': {
'id': '%s/replies' % uri,
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': '%s/replies?only_other_accounts=true&page=true' % uri,
'partOf': '%s/replies' % uri,
'items': [], # TODO: populate with replies
}
}
}
return status_json
def get_replies(status, replies):
''' collection of replies '''
id_slug = status.absolute_id + '/replies'
# TODO only partially implemented
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'Collection',
'first': {
'id': '%s?page=true' % id_slug,
'type': 'CollectionPage',
'next': '%s?only_other_accounts=true&page=true' % id_slug,
'partOf': id_slug,
'items': [get_status(r) for r in replies]
}
}

View file

@ -9,7 +9,7 @@ import requests
from urllib.parse import urlparse from urllib.parse import urlparse
def get_recipients(user, post_privacy, direct_recipients=None): def get_recipients(user, post_privacy, direct_recipients=None, limit=False):
''' deduplicated list of recipient inboxes ''' ''' deduplicated list of recipient inboxes '''
recipients = direct_recipients or [] recipients = direct_recipients or []
if post_privacy == 'direct': if post_privacy == 'direct':
@ -17,7 +17,12 @@ def get_recipients(user, post_privacy, direct_recipients=None):
return [u.inbox for u in recipients] return [u.inbox for u in recipients]
# load all the followers of the user who is sending the message # load all the followers of the user who is sending the message
if not limit:
followers = user.followers.all() followers = user.followers.all()
else:
fedireads_user = limit == 'fedireads'
followers = user.followers.filter(fedireads_user=fedireads_user).all()
if post_privacy == 'public': if post_privacy == 'public':
# post to public shared inboxes # post to public shared inboxes
shared_inboxes = set( shared_inboxes = set(

View file

@ -40,8 +40,17 @@ class ReviewForm(ModelForm):
} }
class CommentForm(ModelForm):
class Meta:
model = models.Status
fields = ['content']
help_texts = {f: None for f in fields}
labels = {'content': 'Comment'}
class EditUserForm(ModelForm): class EditUserForm(ModelForm):
class Meta: class Meta:
model = models.User model = models.User
fields = ['avatar', 'name', 'summary'] fields = ['avatar', 'name', 'summary']
help_texts = {f: None for f in fields} help_texts = {f: None for f in fields}

View file

@ -9,9 +9,10 @@ from django.views.decorators.csrf import csrf_exempt
import json import json
import requests import requests
from fedireads import activitypub
from fedireads import models from fedireads import models
from fedireads import outgoing from fedireads import outgoing
from fedireads.activity import create_review from fedireads.status import create_review, create_status
from fedireads.remote_user import get_or_create_remote_user from fedireads.remote_user import get_or_create_remote_user
@ -33,10 +34,7 @@ def shared_inbox(request):
return HttpResponse(status=401) return HttpResponse(status=401)
response = HttpResponseNotFound() response = HttpResponseNotFound()
if activity['type'] == 'Add': if activity['type'] == 'Follow':
response = handle_incoming_shelve(activity)
elif activity['type'] == 'Follow':
response = handle_incoming_follow(activity) response = handle_incoming_follow(activity)
elif activity['type'] == 'Create': elif activity['type'] == 'Create':
@ -45,7 +43,7 @@ def shared_inbox(request):
elif activity['type'] == 'Accept': elif activity['type'] == 'Accept':
response = handle_incoming_follow_accept(activity) response = handle_incoming_follow_accept(activity)
# TODO: Undo, Remove, etc # TODO: Add, Undo, Remove, etc
return response return response
@ -114,29 +112,7 @@ def get_actor(request, username):
return HttpResponseBadRequest() return HttpResponseBadRequest()
user = models.User.objects.get(localname=username) user = models.User.objects.get(localname=username)
return JsonResponse({ return JsonResponse(activitypub.get_actor(user))
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1'
],
'id': user.actor,
'type': 'Person',
'preferredUsername': user.localname,
'name': user.name,
'inbox': user.inbox,
'followers': '%s/followers' % user.actor,
'following': '%s/following' % user.actor,
'summary': user.summary,
'publicKey': {
'id': '%s/#main-key' % user.actor,
'owner': user.actor,
'publicKeyPem': user.public_key,
},
'endpoints': {
'sharedInbox': user.shared_inbox,
}
})
@csrf_exempt @csrf_exempt
@ -154,7 +130,26 @@ def get_status(request, username, status_id):
if user != status.user: if user != status.user:
return HttpResponseNotFound() return HttpResponseNotFound()
return JsonResponse(status.activity) return JsonResponse(activitypub.get_status(status))
@csrf_exempt
def get_replies(request, username, status_id):
''' ordered collection of replies to a status '''
# TODO: this isn't a full implmentation
if request.method != 'GET':
return HttpResponseBadRequest()
status = models.Status.objects.get(id=status_id)
if status.user.localname != username:
return HttpResponseNotFound()
replies = models.Status.objects.filter(
reply_parent=status
).first()
replies_activity = activitypub.get_replies(status, [replies])
return JsonResponse(replies_activity)
@csrf_exempt @csrf_exempt
@ -165,7 +160,8 @@ def get_followers(request, username):
user = models.User.objects.get(localname=username) user = models.User.objects.get(localname=username)
followers = user.followers followers = user.followers
return format_follow_info(user, request.GET.get('page'), followers) page = request.GET.get('page')
return JsonResponse(activitypub.get_followers(user, page, followers))
@csrf_exempt @csrf_exempt
@ -176,70 +172,8 @@ def get_following(request, username):
user = models.User.objects.get(localname=username) user = models.User.objects.get(localname=username)
following = models.User.objects.filter(followers=user) following = models.User.objects.filter(followers=user)
return format_follow_info(user, request.GET.get('page'), following) page = request.GET.get('page')
return JsonResponse(activitypub.get_following(user, page, following))
def format_follow_info(user, page, follow_queryset):
''' create the activitypub json for followers/following '''
id_slug = '%s/following' % user.actor
if page:
return JsonResponse(get_follow_page(follow_queryset, id_slug, page))
count = follow_queryset.count()
return JsonResponse({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': id_slug,
'type': 'OrderedCollection',
'totalItems': count,
'first': '%s?page=1' % id_slug,
})
def get_follow_page(user_list, id_slug, page):
''' format a list of followers/following '''
page = int(page)
page_length = 10
start = (page - 1) * page_length
end = start + page_length
follower_page = user_list.all()[start:end]
data = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s?page=%d' % (id_slug, page),
'type': 'OrderedCollectionPage',
'totalItems': user_list.count(),
'partOf': id_slug,
'orderedItems': [u.actor for u in follower_page],
}
if end <= user_list.count():
# there are still more pages
data['next'] = '%s?page=%d' % (id_slug, page + 1)
if start > 0:
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
return data
def handle_incoming_shelve(activity):
''' receiving an Add activity (to shelve a book) '''
# TODO what happens here? If it's a remote over, then I think
# I should save both the activity and the ShelfBook entry. But
# I'll do that later.
uuid = activity['id']
models.ShelveActivity.objects.get(uuid=uuid)
'''
book_id = activity['object']['url']
book = openlibrary.get_or_create_book(book_id)
user_ap_id = activity['actor'].replace('https//:', '')
user = models.User.objects.get(actor=user_ap_id)
if not user or not user.local:
return HttpResponseBadRequest()
shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id'])
models.ShelfBook(
shelf=shelf,
book=book,
added_by=user,
).save()
'''
return HttpResponse()
def handle_incoming_follow(activity): def handle_incoming_follow(activity):
@ -248,12 +182,6 @@ def handle_incoming_follow(activity):
to_follow = models.User.objects.get(actor=activity['object']) to_follow = models.User.objects.get(actor=activity['object'])
# figure out who they are # figure out who they are
user = get_or_create_remote_user(activity['actor']) user = get_or_create_remote_user(activity['actor'])
models.FollowActivity(
uuid=activity['id'],
user=user,
followed=to_follow,
content=activity,
)
# TODO: allow users to manually approve requests # TODO: allow users to manually approve requests
outgoing.handle_outgoing_accept(user, to_follow, activity) outgoing.handle_outgoing_accept(user, to_follow, activity)
return HttpResponse() return HttpResponse()
@ -277,37 +205,30 @@ def handle_incoming_create(activity):
if not 'object' in activity: if not 'object' in activity:
return HttpResponseBadRequest() return HttpResponseBadRequest()
# TODO: should only create notes if they are relevent to a book,
# so, not every single thing someone posts on mastodon
response = HttpResponse() response = HttpResponse()
content = activity['object'].get('content')
if activity['object'].get('fedireadsType') == 'Review' and \ if activity['object'].get('fedireadsType') == 'Review' and \
'inReplyTo' in activity['object']: 'inReplyToBook' in activity['object']:
book = activity['object']['inReplyTo'] book = activity['object']['inReplyToBook']
book = book.split('/')[-1] book = book.split('/')[-1]
name = activity['object'].get('name') name = activity['object'].get('name')
content = activity['object'].get('content')
rating = activity['object'].get('rating') rating = activity['object'].get('rating')
if user.local: if user.local:
review_id = activity['object']['id'].split('/')[-1] review_id = activity['object']['id'].split('/')[-1]
review = models.Review.objects.get(id=review_id) models.Review.objects.get(id=review_id)
else: else:
try: try:
review = create_review(user, book, name, content, rating) create_review(user, book, name, content, rating)
except ValueError:
return HttpResponseBadRequest()
elif not user.local:
try:
create_status(user, content)
except ValueError: except ValueError:
return HttpResponseBadRequest() return HttpResponseBadRequest()
models.ReviewActivity.objects.create(
uuid=activity['id'],
user=user,
content=activity['object'],
activity_type=activity['object']['type'],
book=review.book,
)
else:
models.Activity.objects.create(
uuid=activity['id'],
user=user,
content=activity,
activity_type=activity['object']['type']
)
return response return response
@ -321,12 +242,5 @@ def handle_incoming_accept(activity):
# save this relationship in the db # save this relationship in the db
followed.followers.add(user) followed.followers.add(user)
# save the activity record
models.FollowActivity(
uuid=activity['id'],
user=user,
followed=followed,
content=activity,
).save()
return HttpResponse() return HttpResponse()

View file

@ -1,4 +1,4 @@
# Generated by Django 3.0.3 on 2020-02-15 22:50 # Generated by Django 3.0.3 on 2020-02-17 02:39
from django.conf import settings from django.conf import settings
import django.contrib.auth.models import django.contrib.auth.models
@ -56,20 +56,6 @@ class Migration(migrations.Migration):
('objects', django.contrib.auth.models.UserManager()), ('objects', django.contrib.auth.models.UserManager()),
], ],
), ),
migrations.CreateModel(
name='Activity',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('uuid', models.CharField(max_length=255, unique=True)),
('content', fedireads.utils.fields.JSONField(max_length=5000)),
('activity_type', models.CharField(max_length=255)),
('fedireads_type', models.CharField(blank=True, max_length=255, null=True)),
('local', models.BooleanField(default=True)),
('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel( migrations.CreateModel(
name='Author', name='Author',
fields=[ fields=[
@ -118,11 +104,14 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status_type', models.CharField(default='Note', max_length=255)), ('status_type', models.CharField(default='Note', max_length=255)),
('activity', fedireads.utils.fields.JSONField(max_length=5000, null=True)), ('activity_type', models.CharField(default='Note', max_length=255)),
('local', models.BooleanField(default=True)), ('local', models.BooleanField(default=True)),
('privacy', models.CharField(default='public', max_length=255)),
('sensitive', models.BooleanField(default=False)),
('content', models.TextField(blank=True, null=True)), ('content', models.TextField(blank=True, null=True)),
('created_date', models.DateTimeField(auto_now_add=True)), ('created_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)), ('mention_books', models.ManyToManyField(related_name='mention_book', to='fedireads.Book')),
('mention_users', models.ManyToManyField(related_name='mention_user', to=settings.AUTH_USER_MODEL)),
('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')), ('reply_parent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Status')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
], ],
@ -175,27 +164,10 @@ class Migration(migrations.Migration):
name='user_permissions', name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'), field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
), ),
migrations.CreateModel(
name='ShelveActivity',
fields=[
('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Shelf')),
],
bases=('fedireads.activity',),
),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='shelf', name='shelf',
unique_together={('user', 'identifier')}, unique_together={('user', 'identifier')},
), ),
migrations.CreateModel(
name='ReviewActivity',
fields=[
('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
],
bases=('fedireads.activity',),
),
migrations.CreateModel( migrations.CreateModel(
name='Review', name='Review',
fields=[ fields=[
@ -206,12 +178,4 @@ class Migration(migrations.Migration):
], ],
bases=('fedireads.status',), bases=('fedireads.status',),
), ),
migrations.CreateModel(
name='FollowActivity',
fields=[
('activity_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Activity')),
('followed', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='followed', to=settings.AUTH_USER_MODEL)),
],
bases=('fedireads.activity',),
),
] ]

View file

@ -0,0 +1,80 @@
# Generated by Django 3.0.3 on 2020-02-19 01:18
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0001_initial'),
]
operations = [
migrations.RenameField(
model_name='author',
old_name='added_date',
new_name='created_date',
),
migrations.RenameField(
model_name='book',
old_name='added_date',
new_name='created_date',
),
migrations.RemoveField(
model_name='author',
name='updated_date',
),
migrations.RemoveField(
model_name='book',
name='updated_date',
),
migrations.RemoveField(
model_name='shelf',
name='updated_date',
),
migrations.RemoveField(
model_name='user',
name='created_date',
),
migrations.RemoveField(
model_name='user',
name='updated_date',
),
migrations.AddField(
model_name='author',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='book',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='federatedserver',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='federatedserver',
name='created_date',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='shelf',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='shelfbook',
name='content',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='fedireads_user',
field=models.BooleanField(default=True),
),
]

View file

@ -1,6 +1,5 @@
''' bring all the models into the app namespace ''' ''' bring all the models into the app namespace '''
from .book import Shelf, ShelfBook, Book, Author from .book import Shelf, ShelfBook, Book, Author
from .user import User, FederatedServer from .user import User, FederatedServer
from .activity import Activity, ShelveActivity, FollowActivity, \ from .activity import Status, Review
ReviewActivity, Status, Review

View file

@ -3,81 +3,31 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from model_utils.managers import InheritanceManager from model_utils.managers import InheritanceManager
from fedireads.utils.fields import JSONField from fedireads.utils.models import FedireadsModel
# TODO: I don't know that these Activity models should exist, at least in this way
# but I'm not sure what the right approach is for now.
class Activity(models.Model):
''' basic fields for storing activities '''
uuid = models.CharField(max_length=255, unique=True)
user = models.ForeignKey('User', on_delete=models.PROTECT)
content = JSONField(max_length=5000)
# the activitypub activity type (Create, Add, Follow, ...)
activity_type = models.CharField(max_length=255)
# custom types internal to fedireads (Review, Shelve, ...)
fedireads_type = models.CharField(max_length=255, blank=True, null=True)
local = models.BooleanField(default=True)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
objects = InheritanceManager()
class ShelveActivity(Activity): class Status(FedireadsModel):
''' someone put a book on a shelf ''' ''' any post, like a reply to a review, etc '''
book = models.ForeignKey('Book', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
def save(self, *args, **kwargs):
if not self.activity_type:
self.activity_type = 'Add'
self.fedireads_type = 'Shelve'
super().save(*args, **kwargs)
class FollowActivity(Activity):
''' record follow requests sent out '''
followed = models.ForeignKey(
'User',
related_name='followed',
on_delete=models.PROTECT
)
def save(self, *args, **kwargs):
self.activity_type = 'Follow'
super().save(*args, **kwargs)
class ReviewActivity(Activity):
book = models.ForeignKey('Book', on_delete=models.PROTECT)
def save(self, *args, **kwargs):
self.activity_type = 'Note'
self.fedireads_type = 'Review'
super().save(*args, **kwargs)
class Status(models.Model):
''' reply to a review, etc '''
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
status_type = models.CharField(max_length=255, default='Note') status_type = models.CharField(max_length=255, default='Note')
activity = JSONField(max_length=5000, null=True) mention_users = models.ManyToManyField('User', related_name='mention_user')
mention_books = models.ManyToManyField('Book', related_name='mention_book')
activity_type = models.CharField(max_length=255, default='Note')
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
privacy = models.CharField(max_length=255, default='public')
sensitive = models.BooleanField(default=False)
reply_parent = models.ForeignKey( reply_parent = models.ForeignKey(
'self', 'self',
null=True, null=True,
on_delete=models.PROTECT on_delete=models.PROTECT
) )
content = models.TextField(blank=True, null=True)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)
objects = InheritanceManager() objects = InheritanceManager()
class Review(Status): class Review(Status):
''' a book review ''' ''' a book review '''
book = models.ForeignKey('Book', on_delete=models.PROTECT)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
book = models.ForeignKey('Book', on_delete=models.PROTECT)
rating = models.IntegerField( rating = models.IntegerField(
default=0, default=0,
validators=[MinValueValidator(0), MaxValueValidator(5)] validators=[MinValueValidator(0), MaxValueValidator(5)]
@ -85,6 +35,6 @@ class Review(Status):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.status_type = 'Review' self.status_type = 'Review'
self.activity_type = 'Article'
super().save(*args, **kwargs) super().save(*args, **kwargs)

View file

@ -1,9 +1,12 @@
''' database schema for books and shelves ''' ''' database schema for books and shelves '''
from django.db import models from django.db import models
from fedireads.settings import DOMAIN
from fedireads.utils.fields import JSONField from fedireads.utils.fields import JSONField
from fedireads.utils.models import FedireadsModel
class Shelf(models.Model): class Shelf(FedireadsModel):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
identifier = models.CharField(max_length=100) identifier = models.CharField(max_length=100)
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
@ -14,14 +17,19 @@ class Shelf(models.Model):
through='ShelfBook', through='ShelfBook',
through_fields=('shelf', 'book') through_fields=('shelf', 'book')
) )
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) @property
def absolute_id(self):
''' use shelf identifier as absolute id '''
base_path = self.user.absolute_id
model_name = type(self).__name__.lower()
return '%s/%s/%s' % (base_path, model_name, self.identifier)
class Meta: class Meta:
unique_together = ('user', 'identifier') unique_together = ('user', 'identifier')
class ShelfBook(models.Model): class ShelfBook(FedireadsModel):
# many to many join table for books and shelves # many to many join table for books and shelves
book = models.ForeignKey('Book', on_delete=models.PROTECT) book = models.ForeignKey('Book', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
@ -31,13 +39,12 @@ class ShelfBook(models.Model):
null=True, null=True,
on_delete=models.PROTECT on_delete=models.PROTECT
) )
created_date = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
unique_together = ('book', 'shelf') unique_together = ('book', 'shelf')
class Book(models.Model): class Book(FedireadsModel):
''' a non-canonical copy of a work (not book) from open library ''' ''' a non-canonical copy of a work (not book) from open library '''
openlibrary_key = models.CharField(max_length=255, unique=True) openlibrary_key = models.CharField(max_length=255, unique=True)
data = JSONField() data = JSONField()
@ -56,14 +63,17 @@ class Book(models.Model):
null=True, null=True,
on_delete=models.PROTECT on_delete=models.PROTECT
) )
added_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) @property
def absolute_id(self):
''' constructs the absolute reference to any db object '''
base_path = 'https://%s' % DOMAIN
model_name = type(self).__name__.lower()
return '%s/%s/%s' % (base_path, model_name, self.openlibrary_key)
class Author(models.Model): class Author(FedireadsModel):
''' copy of an author from OL ''' ''' copy of an author from OL '''
openlibrary_key = models.CharField(max_length=255) openlibrary_key = models.CharField(max_length=255)
data = JSONField() data = JSONField()
added_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True)

View file

@ -7,6 +7,7 @@ from django.dispatch import receiver
from fedireads.models import Shelf from fedireads.models import Shelf
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
from fedireads.utils.models import FedireadsModel
class User(AbstractUser): class User(AbstractUser):
@ -24,6 +25,7 @@ class User(AbstractUser):
outbox = models.CharField(max_length=255, unique=True) outbox = models.CharField(max_length=255, unique=True)
summary = models.TextField(blank=True, null=True) summary = models.TextField(blank=True, null=True)
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
fedireads_user = models.BooleanField(default=True)
localname = models.CharField( localname = models.CharField(
max_length=255, max_length=255,
null=True, null=True,
@ -33,11 +35,15 @@ class User(AbstractUser):
name = models.CharField(max_length=100, blank=True, null=True) name = models.CharField(max_length=100, blank=True, null=True)
avatar = models.ImageField(upload_to='avatars/', blank=True, null=True) avatar = models.ImageField(upload_to='avatars/', blank=True, null=True)
followers = models.ManyToManyField('self', symmetrical=False) followers = models.ManyToManyField('self', symmetrical=False)
created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) @property
def absolute_id(self):
''' users are identified by their username, so overriding this prop '''
model_name = type(self).__name__.lower()
return 'https://%s/%s/%s' % (DOMAIN, model_name, self.localname)
class FederatedServer(models.Model): class FederatedServer(FedireadsModel):
''' store which server's we federate with ''' ''' store which server's we federate with '''
server_name = models.CharField(max_length=255, unique=True) server_name = models.CharField(max_length=255, unique=True)
# federated, blocked, whatever else # federated, blocked, whatever else
@ -56,10 +62,10 @@ def execute_before_save(sender, instance, *args, **kwargs):
# populate fields for local users # populate fields for local users
instance.localname = instance.username instance.localname = instance.username
instance.username = '%s@%s' % (instance.username, DOMAIN) instance.username = '%s@%s' % (instance.username, DOMAIN)
instance.actor = 'https://%s/user/%s' % (DOMAIN, instance.localname) instance.actor = instance.absolute_id
instance.inbox = 'https://%s/user/%s/inbox' % (DOMAIN, instance.localname) instance.inbox = '%s/inbox' % instance.absolute_id
instance.shared_inbox = 'https://%s/inbox' % DOMAIN instance.shared_inbox = 'https://%s/inbox' % DOMAIN
instance.outbox = 'https://%s/user/%s/outbox' % (DOMAIN, instance.localname) instance.outbox = '%s/outbox' % instance.absolute_id
if not instance.private_key: if not instance.private_key:
random_generator = Random.new().read random_generator = Random.new().read
key = RSA.generate(1024, random_generator) key = RSA.generate(1024, random_generator)

View file

@ -1,20 +1,14 @@
''' handles all the activity coming out of the server ''' ''' handles all the activity coming out of the server '''
from base64 import b64encode
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from datetime import datetime
from django.http import HttpResponseNotFound, JsonResponse from django.http import HttpResponseNotFound, JsonResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
import requests import requests
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import uuid4
from fedireads import activitypub
from fedireads import models from fedireads import models
from fedireads.activity import create_review from fedireads.status import create_review, create_status
from fedireads.remote_user import get_or_create_remote_user from fedireads.remote_user import get_or_create_remote_user
from fedireads.broadcast import get_recipients, broadcast from fedireads.broadcast import get_recipients, broadcast
from fedireads.settings import DOMAIN
@csrf_exempt @csrf_exempt
@ -30,7 +24,6 @@ def outbox(request, username):
min_id = request.GET.get('min_id') min_id = request.GET.get('min_id')
max_id = request.GET.get('max_id') max_id = request.GET.get('max_id')
query_path = user.outbox + '?'
# filters for use in the django queryset min/max # filters for use in the django queryset min/max
filters = {} filters = {}
# params for the outbox page id # params for the outbox page id
@ -41,39 +34,20 @@ def outbox(request, username):
if max_id != None: if max_id != None:
params['max_id'] = max_id params['max_id'] = max_id
filters['id__lte'] = max_id filters['id__lte'] = max_id
collection_id = query_path + urlencode(params)
messages = models.Activity.objects.filter( page_id = user.outbox + '?' + urlencode(params)
statuses = models.Status.objects.filter(
user=user, user=user,
activity_type__in=['Article', 'Note'],
**filters **filters
).all()[:limit] ).all()[:limit]
outbox_page = { return JsonResponse(
'@context': 'https://www.w3.org/ns/activitystreams', activitypub.get_outbox_page(user, page_id, statuses, max_id, min_id)
'id': collection_id, )
'type': 'OrderedCollectionPage',
'partOf': user.outbox,
'orderedItems': [m.content for m in messages],
}
if max_id:
outbox_page['next'] = query_path + \
urlencode({'min_id': max_id, 'page': 'true'})
if min_id:
outbox_page['prev'] = query_path + \
urlencode({'max_id': min_id, 'page': 'true'})
return JsonResponse(outbox_page)
# collection overview # collection overview
size = models.Review.objects.filter(user=user).count() size = models.Status.objects.filter(user=user).count()
return JsonResponse({ return JsonResponse(activitypub.get_outbox(user, size))
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/outbox' % user.actor,
'type': 'OrderedCollection',
'totalItems': size,
'first': '%s/outbox?page=true' % user.actor,
'last': '%s/outbox?min_id=0&page=true' % user.actor
})
def handle_account_search(query): def handle_account_search(query):
@ -97,127 +71,56 @@ def handle_account_search(query):
def handle_outgoing_follow(user, to_follow): def handle_outgoing_follow(user, to_follow):
''' someone local wants to follow someone ''' ''' someone local wants to follow someone '''
uuid = uuid4() activity = activitypub.get_follow_request(user, to_follow)
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://%s/%s' % (DOMAIN, str(uuid)),
'summary': '',
'type': 'Follow',
'actor': user.actor,
'object': to_follow.actor,
}
errors = broadcast(user, activity, [to_follow.inbox]) errors = broadcast(user, activity, [to_follow.inbox])
for error in errors: for error in errors:
# TODO: following masto users is returning 400
raise(error['error']) raise(error['error'])
def handle_outgoing_accept(user, to_follow, activity): def handle_outgoing_accept(user, to_follow, request_activity):
''' send an acceptance message to a follow request ''' ''' send an acceptance message to a follow request '''
to_follow.followers.add(user) to_follow.followers.add(user)
activity = { activity = activitypub.get_accept(to_follow, request_activity)
'@context': 'https://www.w3.org/ns/activitystreams', recipient = get_recipients(to_follow, 'direct', direct_recipients=[user])
'id': 'https://%s/%s#accepts/follows/' % (DOMAIN, to_follow.localname),
'type': 'Accept',
'actor': to_follow.actor,
'object': activity,
}
recipient = get_recipients(
to_follow,
'direct',
direct_recipients=[user]
)
broadcast(to_follow, activity, recipient) broadcast(to_follow, activity, recipient)
def handle_shelve(user, book, shelf): def handle_shelve(user, book, shelf):
''' a local user is getting a book put on their shelf ''' ''' a local user is getting a book put on their shelf '''
# update the database # update the database
# TODO: this should probably happen in incoming instead
models.ShelfBook(book=book, shelf=shelf, added_by=user).save() models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
# send out the activitypub action activity = activitypub.get_add(user, book, shelf)
summary = '%s marked %s as %s' % (
user.username,
book.data['title'],
shelf.name
)
uuid = uuid4()
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'summary': summary,
'type': 'Add',
'actor': user.actor,
'object': {
'type': 'Document',
'name': book.data['title'],
'url': book.openlibrary_key
},
'target': {
'type': 'Collection',
'name': shelf.name,
'id': 'https://%s/user/%s/shelf/%s' % \
(DOMAIN, user.localname, shelf.identifier)
}
}
recipients = get_recipients(user, 'public') recipients = get_recipients(user, 'public')
models.ShelveActivity(
uuid=uuid,
user=user,
content=activity,
shelf=shelf,
book=book,
).save()
broadcast(user, activity, recipients) broadcast(user, activity, recipients)
# tell the world about this cool thing that happened
verb = {
'to-read': 'wants to read',
'reading': 'started reading',
'read': 'finished reading'
}[shelf.identifier]
name = user.name if user.name else user.localname
message = '%s %s %s' % (name, verb, book.data['title'])
status = create_status(user, message, mention_books=[book])
activity = activitypub.get_status(status)
create_activity = activitypub.get_create(user, activity)
broadcast(user, create_activity, recipients)
def handle_unshelve(user, book, shelf): def handle_unshelve(user, book, shelf):
''' a local user is getting a book put on their shelf ''' ''' a local user is getting a book put on their shelf '''
# update the database # update the database
# TODO: this should probably happen in incoming instead
row = models.ShelfBook.objects.get(book=book, shelf=shelf) row = models.ShelfBook.objects.get(book=book, shelf=shelf)
row.delete() row.delete()
# send out the activitypub action activity = activitypub.get_remove(user, book, shelf)
summary = '%s removed %s from %s' % (
user.username,
book.data['title'],
shelf.name
)
uuid = uuid4()
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'summary': summary,
'type': 'Remove',
'actor': user.actor,
'object': {
'type': 'Document',
'name': book.data['title'],
'url': book.openlibrary_key
},
'target': {
'type': 'Collection',
'name': shelf.name,
'id': 'https://%s/user/%s/shelf/%s' % \
(DOMAIN, user.localname, shelf.identifier)
}
}
recipients = get_recipients(user, 'public') recipients = get_recipients(user, 'public')
models.ShelveActivity(
uuid=uuid,
user=user,
content=activity,
shelf=shelf,
book=book,
activity_type='Remove',
).save()
broadcast(user, activity, recipients) broadcast(user, activity, recipients)
@ -226,63 +129,25 @@ def handle_review(user, book, name, content, rating):
# validated and saves the review in the database so it has an id # validated and saves the review in the database so it has an id
review = create_review(user, book, name, content, rating) review = create_review(user, book, name, content, rating)
review_path = 'https://%s/user/%s/status/%d' % \ review_activity = activitypub.get_review(review)
(DOMAIN, user.localname, review.id) review_create_activity = activitypub.get_create(user, review_activity)
book_path = 'https://%s/book/%s' % (DOMAIN, review.book.openlibrary_key) fr_recipients = get_recipients(user, 'public', limit='fedireads')
broadcast(user, review_create_activity, fr_recipients)
now = datetime.utcnow().isoformat() #TODO: should this be http_date? # re-format the activity for non-fedireads servers
review_activity = { article_activity = activitypub.get_review_article(review)
'id': review_path, article_create_activity = activitypub.get_create(user, article_activity)
'url': review_path,
'inReplyTo': book_path,
'published': now,
'attributedTo': user.actor,
# TODO: again, assuming all posts are public
'to': ['https://www.w3.org/ns/activitystreams#Public'],
'cc': ['https://%s/user/%s/followers' % (DOMAIN, user.localname)],
'sensitive': False, # TODO: allow content warning/sensitivity
'content': content,
'type': 'Note',
'fedireadsType': 'Review',
'name': name,
'rating': rating, # fedireads-only custom field
'attachment': [], # TODO: the book cover
'replies': {
'id': '%s/replies' % review_path,
'type': 'Collection',
'first': {
'type': 'CollectionPage',
'next': '%s/replies?only_other_accounts=true&page=true' % \
review_path,
'partOf': '%s/replies' % review_path,
'items': [], # TODO: populate with replies
}
}
}
review.activity = review_activity
review.save()
signer = pkcs1_15.new(RSA.import_key(user.private_key)) other_recipients = get_recipients(user, 'public', limit='other')
signed_message = signer.sign(SHA256.new(content.encode('utf8'))) broadcast(user, article_create_activity, other_recipients)
create_activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': '%s/activity' % review_path,
'type': 'Create',
'actor': user.actor,
'published': now,
'to': ['%s/followers' % user.actor], def handle_comment(user, review, content):
'cc': ['https://www.w3.org/ns/activitystreams#Public'], ''' post a review '''
# validated and saves the comment in the database so it has an id
'object': review_activity, comment = create_status(user, content, reply_parent=review)
'signature': { comment_activity = activitypub.get_status(comment)
'type': 'RsaSignature2017', create_activity = activitypub.get_create(user, comment_activity)
'creator': 'https://%s/user/%s#main-key' % (DOMAIN, user.localname),
'created': now,
'signatureValue': b64encode(signed_message).decode('utf8'),
}
}
recipients = get_recipients(user, 'public') recipients = get_recipients(user, 'public')
broadcast(user, create_activity, recipients) broadcast(user, create_activity, recipients)

View file

@ -41,7 +41,8 @@ def get_or_create_remote_user(actor):
shared_inbox=shared_inbox, shared_inbox=shared_inbox,
# TODO: I'm never actually using this for remote users # TODO: I'm never actually using this for remote users
public_key=data.get('publicKey').get('publicKeyPem'), public_key=data.get('publicKey').get('publicKeyPem'),
local=False local=False,
fedireads_user=False,
) )
except KeyError: except KeyError:
return False return False

View file

@ -9,15 +9,12 @@ def create_review(user, possible_book, name, content, rating):
# throws a value error if the book is not found # throws a value error if the book is not found
book = get_or_create_book(possible_book) book = get_or_create_book(possible_book)
# sanitize review html content = sanitize(content)
parser = InputHtmlParser()
parser.feed(content)
content = parser.get_output()
# no ratings outside of 0-5 # no ratings outside of 0-5
rating = rating if 0 <= rating <= 5 else 0 rating = rating if 0 <= rating <= 5 else 0
review = models.Review.objects.create( return models.Review.objects.create(
user=user, user=user,
book=book, book=book,
name=name, name=name,
@ -25,6 +22,31 @@ def create_review(user, possible_book, name, content, rating):
content=content, content=content,
) )
return review
def create_status(user, content, reply_parent=None, mention_books=None):
''' a status update '''
# TODO: handle @'ing users
# sanitize input html
parser = InputHtmlParser()
parser.feed(content)
content = parser.get_output()
status = models.Status.objects.create(
user=user,
content=content,
reply_parent=reply_parent,
)
if mention_books:
for book in mention_books:
status.mention_books.add(book)
return status
def sanitize(content):
''' remove invalid html from free text '''
parser = InputHtmlParser()
parser.feed(content)
return parser.get_output()

View file

@ -55,40 +55,34 @@
<h2> <h2>
{% include 'snippets/avatar.html' with user=activity.user %} {% include 'snippets/avatar.html' with user=activity.user %}
{% include 'snippets/username.html' with user=activity.user %} {% include 'snippets/username.html' with user=activity.user %}
{% if activity.fedireads_type == 'Shelve' %} {% if activity.status_type == 'Review' %}
{# display a reading/shelving activity #}
{% if activity.shelf.identifier == 'to-read' %}
wants to read
{% elif activity.shelf.identifier == 'read' %}
finished reading
{% elif activity.shelf.identifier == 'reading' %}
started reading
{% else %}
shelved in "{{ activity.shelf.name }}"
{% endif %}
</h2>
<div class="book-preview">
{% include 'snippets/book.html' with book=activity.book size=large description=True %}
</div>
<div class="interaction"><button>⭐️ Like</button></div>
{% elif activity.fedireads_type == 'Review' %}
{# display a review #} {# display a review #}
reviewed {{ activity.book.data.title }} reviewed {{ activity.book.data.title }}
</h2> </h2>
<div class="book-preview review"> <div class="book-preview review">
{% include 'snippets/book.html' with book=activity.book size=large %} {% include 'snippets/book.html' with book=activity.book size=large %}
<h3>{{ activity.content.name }}</h3> <h3>{{ activity.name }}</h3>
<p>{{ activity.content.rating | stars }}</p> <p>{{ activity.rating | stars }}</p>
<p>{{ activity.content.content | safe }}</p> <p>{{ activity.content | safe }}</p>
</div> </div>
<div class="interaction"><button>⭐️ Like</button></div> <div class="interaction">
{% elif activity.activity_type == 'Follow' %} <button>⭐️ Like</button>
started following someone <form name="comment" action="/comment" method="post">
</h2> {% csrf_token %}
{% elif activity.activity_type == 'Note' %} <input type="hidden" name="review" value="{{ activity.id }}"></input>
{{ comment_form.content }}
<button type="submit">Comment</button>
</form>
</div>
{% elif activity.status_type == 'Note' %}
posted</h2> posted</h2>
{{ activity.content.object.content | safe }} {{ activity.content | safe }}
{% for book in activity.mention_books.all %}
<div class="book-preview review">
{% include 'snippets/book.html' with book=book size=large description=True %}
</div>
{% endfor %}
{% else %} {% else %}
{# generic handling for a misc activity, which perhaps should not be displayed at all #} {# generic handling for a misc activity, which perhaps should not be displayed at all #}
did {{ activity.activity_type }} did {{ activity.activity_type }}

View file

@ -92,7 +92,7 @@
<img src="/images/{{ book.cover }}" class="book-cover small"> <img src="/images/{{ book.cover }}" class="book-cover small">
</td> </td>
<td> <td>
<a href="{{ book.openlibrary_key }}">{{ book.data.title }}</a> <a href="/book/{{ book.openlibrary_key }}">{{ book.data.title }}</a>
</td> </td>
<td> <td>
{{ book.authors.first.data.name }} {{ book.authors.first.data.name }}

View file

@ -17,14 +17,17 @@ urlpatterns = [
re_path(r'^user/(?P<username>\w+)/followers/?$', incoming.get_followers), re_path(r'^user/(?P<username>\w+)/followers/?$', incoming.get_followers),
re_path(r'^user/(?P<username>\w+)/following/?$', incoming.get_following), re_path(r'^user/(?P<username>\w+)/following/?$', incoming.get_following),
re_path( re_path(
r'^user/(?P<username>\w+)/status/(?P<status_id>\d+)/?$', r'^user/(?P<username>\w+)/(status|review)/(?P<status_id>\d+)/?$',
incoming.get_status incoming.get_status
), ),
re_path( re_path(
r'^user/(?P<username>\w+)/status/(?P<status_id>\d+)/activity/?$', r'^user/(?P<username>\w+)/(status|review)/(?P<status_id>\d+)/activity/?$',
incoming.get_status incoming.get_status
), ),
re_path(r'^user/(?P<username>\w+)/status/?$', incoming.get_following), re_path(
r'^user/(?P<username>\w+)/(status|review)/(?P<status_id>\d+)/replies/?$',
incoming.get_replies
),
# TODO: shelves need pages in the UI and for their activitypub Collection # TODO: shelves need pages in the UI and for their activitypub Collection
# .well-known endpoints # .well-known endpoints
@ -47,6 +50,7 @@ urlpatterns = [
# internal action endpoints # internal action endpoints
re_path(r'^review/?$', views.review), re_path(r'^review/?$', views.review),
re_path(r'^comment/?$', views.comment),
re_path( re_path(
r'^shelve/(?P<username>\w+)/(?P<shelf_id>[\w-]+)/(?P<book_id>\d+)/?$', r'^shelve/(?P<username>\w+)/(?P<shelf_id>[\w-]+)/(?P<book_id>\d+)/?$',
views.shelve views.shelve

21
fedireads/utils/models.py Normal file
View file

@ -0,0 +1,21 @@
from django.db import models
from fedireads.settings import DOMAIN
class FedireadsModel(models.Model):
content = models.TextField(blank=True, null=True)
created_date = models.DateTimeField(auto_now_add=True)
@property
def absolute_id(self):
''' constructs the absolute reference to any db object '''
base_path = 'https://%s' % DOMAIN
if self.user:
base_path = self.user.absolute_id
model_name = type(self).__name__.lower()
return '%s/%s/%d' % (base_path, model_name, self.id)
class Meta:
abstract = True

View file

@ -29,22 +29,21 @@ def home(request):
# books new to the instance, for discovery # books new to the instance, for discovery
recent_books = models.Book.objects.order_by( recent_books = models.Book.objects.order_by(
'-added_date' '-created_date'
)[:5] )[:5]
# status updates for your follow network # status updates for your follow network
following = models.User.objects.filter( following = models.User.objects.filter(
Q(followers=request.user) | Q(id=request.user.id) Q(followers=request.user) | Q(id=request.user.id)
) )
# TODO: this is fundamentally not how the feed should work I think? it
# should do something smart with inboxes. (in this implementation it would activities = models.Status.objects.filter(
# show DMs meant for other local users) Q(user__in=following, privacy='public') | Q(mention_users=request.user)
activities = models.Activity.objects.filter(
user__in=following,
).select_subclasses().order_by( ).select_subclasses().order_by(
'-created_date' '-created_date'
)[:10] )[:10]
comment_form = forms.CommentForm()
data = { data = {
'user': request.user, 'user': request.user,
'reading': reading, 'reading': reading,
@ -52,6 +51,7 @@ def home(request):
'recent_books': recent_books, 'recent_books': recent_books,
'user_books': user_books, 'user_books': user_books,
'activities': activities, 'activities': activities,
'comment_form': comment_form,
} }
return TemplateResponse(request, 'feed.html', data) return TemplateResponse(request, 'feed.html', data)
@ -252,6 +252,18 @@ def review(request):
return redirect('/book/%s' % book_identifier) return redirect('/book/%s' % book_identifier)
def comment(request):
''' respond to a book review '''
form = forms.CommentForm(request.POST)
# this is a bit of a formality, the form is just one text field
if not form.is_valid():
return redirect('/')
review_id = request.POST['review']
parent = models.Review.objects.get(id=review_id)
outgoing.handle_comment(request.user, parent, form.data['content'])
return redirect('/')
@login_required @login_required
def follow(request): def follow(request):
''' follow another user, here or abroad ''' ''' follow another user, here or abroad '''