This commit is contained in:
Mouse Reeve 2020-01-27 18:47:54 -08:00
parent 0345a5d9ff
commit 7ee59c5fd5
10 changed files with 407 additions and 256 deletions

View file

@ -1,121 +0,0 @@
''' generates activitypub formatted objects '''
from uuid import uuid4
from fedireads.settings import DOMAIN
from datetime import datetime
def outbox_collection(user, size):
''' outbox okay cool '''
return {
'@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 shelve_activity(user, book, shelf):
''' a user puts a book on a shelf.
activitypub action type Add
https://www.w3.org/ns/activitystreams#Add '''
book_title = book.data['title']
summary = '%s added %s to %s' % (
user.username,
book_title,
shelf.name
)
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'summary': summary,
'type': 'Add',
'actor': user.actor,
'object': {
'type': 'Document',
'name': book_title,
'url': book.openlibary_key
},
'target': {
'type': 'Collection',
'name': shelf.name,
'id': shelf.activitypub_id
}
}
def create_activity(user, obj):
''' wraps any object we're broadcasting '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'type': 'Create',
'actor': user.actor,
'to': ['%s/followers' % user.actor],
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': obj,
}
def note_object(user, content):
''' a lil post '''
uuid = uuid4()
return {
'id': str(uuid),
'type': 'Note',
'published': datetime.utcnow().isoformat(),
'attributedTo': user.actor,
'content': content,
'to': 'https://www.w3.org/ns/activitystreams#Public'
}
def follow_request(user, follow):
''' ask to be friends '''
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'summary': '',
'type': 'Follow',
'actor': user.actor,
'object': follow,
}
def accept_follow(activity, user):
''' say YES! to a user '''
uuid = uuid4()
return {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://%s/%s' % (DOMAIN, uuid),
'type': 'Accept',
'actor': user.actor,
'object': activity,
}
def actor(user):
''' format an actor object from a user '''
return {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1'
],
'id': user.actor,
'type': 'Person',
'preferredUsername': user.username,
'inbox': inbox(user),
'followers': '%s/followers' % user.actor,
'publicKey': {
'id': '%s/#main-key' % user.actor,
'owner': user.actor,
'publicKeyPem': user.public_key,
}
}
def inbox(user):
''' describe an inbox '''
return '%s/inbox' % (user.actor)

View file

@ -9,10 +9,9 @@ from django.http import HttpResponse, HttpResponseBadRequest, \
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from fedireads.settings import DOMAIN from fedireads.settings import DOMAIN
from fedireads import models from fedireads import models
from fedireads import openlibrary
import fedireads.activitypub_templates as templates
import json import json
import requests import requests
from uuid import uuid4
def webfinger(request): def webfinger(request):
''' allow other servers to ask about a user ''' ''' allow other servers to ask about a user '''
@ -23,12 +22,7 @@ def webfinger(request):
user = models.User.objects.filter(full_username=ap_id).first() user = models.User.objects.filter(full_username=ap_id).first()
if not user: if not user:
return HttpResponseNotFound('No account found') return HttpResponseNotFound('No account found')
return JsonResponse(format_webfinger(user)) return JsonResponse({
def format_webfinger(user):
''' helper function to create structured webfinger json '''
return {
'subject': 'acct:%s' % (user.full_username), 'subject': 'acct:%s' % (user.full_username),
'links': [ 'links': [
{ {
@ -37,21 +31,40 @@ def format_webfinger(user):
'href': user.actor 'href': user.actor
} }
] ]
} })
@csrf_exempt @csrf_exempt
def get_actor(request, username): def get_actor(request, username):
''' return an activitypub actor object ''' ''' return an activitypub actor object '''
if request.method != 'GET':
return HttpResponseBadRequest()
user = models.User.objects.get(username=username) user = models.User.objects.get(username=username)
return JsonResponse(templates.actor(user)) return JsonResponse({
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1'
],
'id': user.actor,
'type': 'Person',
'preferredUsername': user.username,
'inbox': format_inbox(user),
'followers': '%s/followers' % user.actor,
'publicKey': {
'id': '%s/#main-key' % user.actor,
'owner': user.actor,
'publicKeyPem': user.public_key,
}
})
@csrf_exempt @csrf_exempt
def inbox(request, username): def inbox(request, username):
''' incoming activitypub events ''' ''' incoming activitypub events '''
if request.method == 'GET': if request.method == 'GET':
# return a collection of something? # TODO: return a collection of something?
return JsonResponse({}) return JsonResponse({})
# TODO: RSA key verification # TODO: RSA key verification
@ -60,30 +73,47 @@ def inbox(request, username):
activity = json.loads(request.body) activity = json.loads(request.body)
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
return HttpResponseBadRequest return HttpResponseBadRequest
# TODO: should do some kind of checking if the user accepts
# this action from the sender
# but this will just throw an error if the user doesn't exist I guess
models.User.objects.get(username=username)
if activity['type'] == 'Add': if activity['type'] == 'Add':
handle_add(activity) return handle_add(activity)
if activity['type'] == 'Follow': if activity['type'] == 'Follow':
response = handle_follow(activity) return handle_incoming_follow(activity)
return JsonResponse(response)
return HttpResponse() return HttpResponse()
def handle_add(activity): def handle_add(activity):
''' adding a book to a shelf ''' ''' 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_id = activity['object']['url']
book = openlibrary.get_or_create_book(book_id) book = openlibrary.get_or_create_book(book_id)
user_ap_id = activity['actor'].replace('https//:', '') user_ap_id = activity['actor'].replace('https//:', '')
user = models.User.objects.get(actor=user_ap_id) 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']) shelf = models.Shelf.objects.get(activitypub_id=activity['target']['id'])
models.ShelfBook( models.ShelfBook(
shelf=shelf, shelf=shelf,
book=book, book=book,
added_by=user, added_by=user,
).save() ).save()
'''
return HttpResponse()
def handle_follow(activity): def handle_incoming_follow(activity):
''' '''
{ {
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
@ -99,42 +129,160 @@ def handle_follow(activity):
# figure out who they are # figure out who they are
user = get_or_create_remote_user(activity) user = get_or_create_remote_user(activity)
following.followers.add(user) following.followers.add(user)
# accept the request # verify uuid and accept the request
return templates.accept_follow(activity, following) models.FollowActivity(
uuid=activity['id'],
user=user,
followed=following,
content=activity,
activity_type='Follow',
)
uuid = uuid4()
return JsonResponse({
'@context': 'https://www.w3.org/ns/activitystreams',
'id': 'https://%s/%s' % (DOMAIN, uuid),
'type': 'Accept',
'actor': user.actor,
'object': activity,
})
def handle_outgoing_follow(user, to_follow):
''' someone local wants to follow someone '''
uuid = uuid4()
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(uuid),
'summary': '',
'type': 'Follow',
'actor': user.actor,
'object': to_follow,
}
broadcast(user, activity, [format_inbox(to_follow)])
models.FollowActivity(
uuid=uuid,
user=user,
content=activity,
).save()
def handle_shelve(user, book, shelf):
''' gettin organized '''
# update the database
models.ShelfBook(book=book, shelf=shelf, added_by=user).save()
# send out the activitypub action
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': shelf.activitypub_id
}
}
recipients = [format_inbox(u) for u in user.followers.all()]
models.ShelveActivity(
uuid=uuid,
user=user,
content=activity,
activity_type='Add',
shelf=shelf,
book=book,
).save()
broadcast(user, activity, recipients)
def handle_review(user, book, name, content, rating):
''' post a review '''
review_uuid = uuid4()
obj = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(review_uuid),
'type': 'Article',
'published': datetime.utcnow().isoformat(),
'attributedTo': user.actor,
'content': content,
'inReplyTo': book.activitypub_id,
'rating': rating, # fedireads-only custom field
'to': 'https://www.w3.org/ns/activitystreams#Public'
}
recipients = [format_inbox(u) for u in user.followers.all()]
create_uuid = uuid4()
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'id': str(create_uuid),
'type': 'Create',
'actor': user.actor,
'to': ['%s/followers' % user.actor],
'cc': ['https://www.w3.org/ns/activitystreams#Public'],
'object': obj,
}
models.Review(
uuid=create_uuid,
user=user,
content=activity,
activity_type='Article',
book=book,
work=book.works.first(),
name=name,
rating=rating,
review_content=content,
).save()
broadcast(user, activity, recipients)
@csrf_exempt @csrf_exempt
def outbox(request, username): def outbox(request, username):
''' outbox for the requested user ''' ''' outbox for the requested user '''
user = models.User.objects.get(username=username) user = models.User.objects.get(username=username)
size = models.Message.objects.filter(user=user).count() size = models.Review.objects.filter(user=user).count()
if request.method == 'GET': if request.method == 'GET':
# list of activities # list of activities
return JsonResponse(templates.outbox_collection(user, size)) return JsonResponse({
'@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
})
# TODO: paginated list of messages
data = request.body.decode('utf-8') #data = request.body.decode('utf-8')
if data.activity.type == 'Follow':
handle_follow(data)
return HttpResponse() return HttpResponse()
def broadcast_activity(sender, obj, recipients): def broadcast(sender, action, recipients):
''' sign and send out the actions '''
activity = templates.create_activity(sender, obj)
# store message in database
models.Message(user=sender, content=activity).save()
for recipient in recipients:
broadcast(sender, activity, recipient)
def broadcast_follow(sender, action, destination):
''' send a follow request '''
broadcast(sender, action, destination)
def broadcast(sender, action, destination):
''' send out an event to all followers ''' ''' send out an event to all followers '''
for recipient in recipients:
sign_and_send(sender, action, recipient)
def sign_and_send(sender, action, destination):
''' crpyto whatever and http junk '''
inbox_fragment = '/api/u/%s/inbox' % (sender.username) inbox_fragment = '/api/u/%s/inbox' % (sender.username)
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
message_to_sign = '''(request-target): post %s message_to_sign = '''(request-target): post %s
@ -175,3 +323,6 @@ def get_or_create_remote_user(activity):
return user return user
def format_inbox(user):
''' describe an inbox '''
return '%s/inbox' % (user.actor)

View file

@ -1,4 +1,4 @@
# Generated by Django 2.0.13 on 2020-01-27 05:42 # Generated by Django 3.0.2 on 2020-01-28 02:46
from django.conf import settings from django.conf import settings
import django.contrib.auth.models import django.contrib.auth.models
@ -14,7 +14,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
('auth', '0009_alter_user_last_name_max_length'), ('auth', '0011_update_proxy_permissions'),
] ]
operations = [ operations = [
@ -53,11 +53,23 @@ 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', django.contrib.postgres.fields.jsonb.JSONField(max_length=5000)),
('activity_type', models.CharField(max_length=255)),
('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=[
('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')),
('openlibary_key', models.CharField(max_length=255)), ('openlibrary_key', models.CharField(max_length=255)),
('data', django.contrib.postgres.fields.jsonb.JSONField()), ('data', django.contrib.postgres.fields.jsonb.JSONField()),
('added_date', models.DateTimeField(auto_now_add=True)), ('added_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)), ('updated_date', models.DateTimeField(auto_now=True)),
@ -68,7 +80,7 @@ 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')),
('activitypub_id', models.CharField(max_length=255)), ('activitypub_id', models.CharField(max_length=255)),
('openlibary_key', models.CharField(max_length=255)), ('openlibrary_key', models.CharField(max_length=255)),
('data', django.contrib.postgres.fields.jsonb.JSONField()), ('data', django.contrib.postgres.fields.jsonb.JSONField()),
('added_date', models.DateTimeField(auto_now_add=True)), ('added_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)), ('updated_date', models.DateTimeField(auto_now=True)),
@ -76,16 +88,6 @@ class Migration(migrations.Migration):
('authors', models.ManyToManyField(to='fedireads.Author')), ('authors', models.ManyToManyField(to='fedireads.Author')),
], ],
), ),
migrations.CreateModel(
name='Message',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', django.contrib.postgres.fields.jsonb.JSONField(max_length=5000)),
('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='Shelf', name='Shelf',
fields=[ fields=[
@ -99,6 +101,23 @@ class Migration(migrations.Migration):
('updated_date', models.DateTimeField(auto_now=True)), ('updated_date', models.DateTimeField(auto_now=True)),
], ],
), ),
migrations.CreateModel(
name='Work',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('openlibrary_key', models.CharField(max_length=255)),
('data', django.contrib.postgres.fields.jsonb.JSONField()),
('added_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='Note',
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')),
],
bases=('fedireads.activity',),
),
migrations.CreateModel( migrations.CreateModel(
name='ShelfBook', name='ShelfBook',
fields=[ fields=[
@ -108,16 +127,9 @@ class Migration(migrations.Migration):
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')), ('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')), ('shelf', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Shelf')),
], ],
), options={
migrations.CreateModel( 'unique_together': {('book', 'shelf')},
name='Work', },
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('openlibary_key', models.CharField(max_length=255)),
('data', django.contrib.postgres.fields.jsonb.JSONField()),
('added_date', models.DateTimeField(auto_now_add=True)),
('updated_date', models.DateTimeField(auto_now=True)),
],
), ),
migrations.AddField( migrations.AddField(
model_name='shelf', model_name='shelf',
@ -139,12 +151,37 @@ class Migration(migrations.Migration):
name='works', name='works',
field=models.ManyToManyField(to='fedireads.Work'), field=models.ManyToManyField(to='fedireads.Work'),
), ),
migrations.AlterUniqueTogether( migrations.CreateModel(
name='shelfbook', name='ShelveActivity',
unique_together={('book', 'shelf')}, 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', 'name')}, unique_together={('user', 'name')},
), ),
migrations.CreateModel(
name='Review',
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')),
('name', models.TextField()),
('rating', models.IntegerField(default=0)),
('review_content', models.TextField()),
('book', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Book')),
('work', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fedireads.Work')),
],
bases=('fedireads.activity',),
),
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

@ -10,15 +10,21 @@ import re
class User(AbstractUser): class User(AbstractUser):
''' a user who wants to read books ''' ''' a user who wants to read books '''
full_username = models.CharField(max_length=255, blank=True, null=True, unique=True) full_username = models.CharField(
max_length=255,
blank=True,
null=True,
unique=True
)
private_key = models.TextField(blank=True, null=True) private_key = models.TextField(blank=True, null=True)
public_key = models.TextField(blank=True, null=True) public_key = models.TextField(blank=True, null=True)
api_key = models.CharField(max_length=255, blank=True, null=True) api_key = models.CharField(max_length=255, blank=True, null=True)
actor = models.CharField(max_length=255) actor = models.CharField(max_length=255)
local = models.BooleanField(default=True) local = models.BooleanField(default=True)
# TODO: a field for if non-local users are readers or others
followers = models.ManyToManyField('self', symmetrical=False)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
followers = models.ManyToManyField('self', symmetrical=False)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# give a new user keys # give a new user keys
@ -40,8 +46,9 @@ class User(AbstractUser):
def execute_after_save(sender, instance, created, *args, **kwargs): def execute_after_save(sender, instance, created, *args, **kwargs):
''' create shelves for new users ''' ''' create shelves for new users '''
# TODO: how are remote users handled? what if they aren't readers? # TODO: how are remote users handled? what if they aren't readers?
if not created: if not instance.local or not created:
return return
shelves = [{ shelves = [{
'name': 'To Read', 'name': 'To Read',
'type': 'to-read', 'type': 'to-read',
@ -62,14 +69,58 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
).save() ).save()
class Message(models.Model): class Activity(models.Model):
''' any kind of user post, incl. reviews, replies, and status updates ''' ''' basic fields for storing activities '''
uuid = models.CharField(max_length=255, unique=True)
user = models.ForeignKey('User', on_delete=models.PROTECT) user = models.ForeignKey('User', on_delete=models.PROTECT)
content = JSONField(max_length=5000) content = JSONField(max_length=5000)
activity_type = models.CharField(max_length=255)
created_date = models.DateTimeField(auto_now_add=True) created_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
class ShelveActivity(Activity):
''' someone put a book on a shelf '''
book = models.ForeignKey('Book', on_delete=models.PROTECT)
shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT)
class FollowActivity(Activity):
''' record follow requests sent out '''
followed = models.ForeignKey(
'User',
related_name='followed',
on_delete=models.PROTECT
)
def save(self, *args, **kwargs):
if not self.activity_type:
self.activity_type = 'Follow'
super().save(*args, **kwargs)
class Review(Activity):
''' a book review '''
book = models.ForeignKey('Book', on_delete=models.PROTECT)
work = models.ForeignKey('Work', on_delete=models.PROTECT)
name = models.TextField()
rating = models.IntegerField(default=0)
review_content = models.TextField()
def save(self, *args, **kwargs):
if not self.activity_type:
self.activity_type = 'Article'
super().save(*args, **kwargs)
class Note(Activity):
''' reply to a review, etc '''
def save(self, *args, **kwargs):
if not self.activity_type:
self.activity_type = 'Note'
super().save(*args, **kwargs)
class Shelf(models.Model): class Shelf(models.Model):
activitypub_id = models.CharField(max_length=255) activitypub_id = models.CharField(max_length=255)
identifier = models.CharField(max_length=255) identifier = models.CharField(max_length=255)
@ -96,7 +147,8 @@ class Shelf(models.Model):
re.sub(r'\W', '-', self.name).lower() re.sub(r'\W', '-', self.name).lower()
) )
if not self.activitypub_id: if not self.activitypub_id:
self.activitypub_id = 'https://%s/shelf/%s' % (DOMAIN, self.identifier) self.activitypub_id = 'https://%s/shelf/%s' % \
(DOMAIN, self.identifier)
super().save(*args, **kwargs) super().save(*args, **kwargs)
@ -111,6 +163,7 @@ class ShelfBook(models.Model):
on_delete=models.PROTECT on_delete=models.PROTECT
) )
added_date = models.DateTimeField(auto_now_add=True) added_date = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
unique_together = ('book', 'shelf') unique_together = ('book', 'shelf')
@ -118,7 +171,7 @@ class ShelfBook(models.Model):
class Book(models.Model): class Book(models.Model):
''' a non-canonical copy from open library ''' ''' a non-canonical copy from open library '''
activitypub_id = models.CharField(max_length=255) activitypub_id = models.CharField(max_length=255)
openlibary_key = models.CharField(max_length=255) openlibrary_key = models.CharField(max_length=255)
data = JSONField() data = JSONField()
works = models.ManyToManyField('Work') works = models.ManyToManyField('Work')
authors = models.ManyToManyField('Author') authors = models.ManyToManyField('Author')
@ -138,19 +191,20 @@ class Book(models.Model):
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.activitypub_id = '%s%s' % (OL_URL, self.openlibary_key) self.activitypub_id = '%s%s' % (OL_URL, self.openlibrary_key)
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Work(models.Model): class Work(models.Model):
''' encompassses all editions of a book ''' ''' encompassses all editions of a book '''
openlibary_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) added_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)
class Author(models.Model): class Author(models.Model):
openlibary_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) added_date = models.DateTimeField(auto_now_add=True)
updated_date = models.DateTimeField(auto_now=True) updated_date = models.DateTimeField(auto_now=True)

View file

@ -12,11 +12,11 @@ def get_or_create_book(olkey, user=None, update=True):
# get the existing entry from our db, if it exists # get the existing entry from our db, if it exists
try: try:
book = Book.objects.get(openlibary_key=olkey) book = Book.objects.get(openlibrary_key=olkey)
if not update: if not update:
return book return book
except ObjectDoesNotExist: except ObjectDoesNotExist:
book = Book(openlibary_key=olkey) book = Book(openlibrary_key=olkey)
data = response.json() data = response.json()
book.data = data book.data = data
if user and user.is_authenticated: if user and user.is_authenticated:
@ -33,22 +33,22 @@ def get_or_create_book(olkey, user=None, update=True):
def get_or_create_work(olkey): def get_or_create_work(olkey):
''' load em up ''' ''' load em up '''
try: try:
work = Work.objects.get(openlibary_key=olkey) work = Work.objects.get(openlibrary_key=olkey)
except ObjectDoesNotExist: except ObjectDoesNotExist:
response = requests.get(OL_URL + olkey + '.json') response = requests.get(OL_URL + olkey + '.json')
data = response.json() data = response.json()
work = Work(openlibary_key=olkey, data=data) work = Work(openlibrary_key=olkey, data=data)
work.save() work.save()
return work return work
def get_or_create_author(olkey): def get_or_create_author(olkey):
''' load that author ''' ''' load that author '''
try: try:
author = Author.objects.get(openlibary_key=olkey) author = Author.objects.get(openlibrary_key=olkey)
except ObjectDoesNotExist: except ObjectDoesNotExist:
response = requests.get(OL_URL + olkey + '.json') response = requests.get(OL_URL + olkey + '.json')
data = response.json() data = response.json()
author = Author(openlibary_key=olkey, data=data) author = Author(openlibrary_key=olkey, data=data)
author.save() author.save()
return author return author

View file

@ -25,9 +25,9 @@ SECRET_KEY = '7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr'
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ['localhost', 'ff2cb3e9.ngrok.io']
DOMAIN = 'ff2cb3e9.ngrok.io' DOMAIN = 'bd352ee8.ngrok.io'
ALLOWED_HOSTS = ['localhost', DOMAIN]
OL_URL = 'https://openlibrary.org' OL_URL = 'https://openlibrary.org'
# Application definition # Application definition

View file

@ -0,0 +1,27 @@
{% extends 'layout.html' %}
{% block content %}
<div id="main">
<div class="book-profile">
<img class="book-cover" src="/static/images/med.jpg">
<h1>{{ book.data.title }}</h1>
by {{ book.authors.first.data.name }}
{{ rating }} stars
</div>
<form name="review" action="/review/" method="post">
<input type="hidden" name="book" value="{{ book.openlibrary_key }}"></input>
<input type="text" name="name"></input>
<textarea name="content">Your review</textarea>
<input type="number" name="rating"></input>
<input type="submit" value="Post review"></input>
</form>
<div class="reviews">
<h2>Reviews</h2>
{% for review in reviews %}
<p><span class="review-title">{{ review.name }}</span>{{ review.rating }} stars, by {{ review.user.username }}</p>
<p>{{ review.review_content }}</p>
{% endfor %}
</div>
</div>
{% endblock %}

View file

@ -6,7 +6,7 @@
{% for book in shelf.books.all %} {% for book in shelf.books.all %}
<div class="book-preview"> <div class="book-preview">
<img class="cover" src="static/images/small.jpg"> <img class="cover" src="static/images/small.jpg">
<p class="title">{{ book.data.title }}</p> <p class="title"><a href="{{ book.openlibrary_key }}">{{ book.data.title }}</a></p>
<p>by <a href="" class="author">{{ book.authors.first.data.name }}</a></p> <p>by <a href="" class="author">{{ book.authors.first.data.name }}</a></p>
{% if shelf.type == 'reading' %} {% if shelf.type == 'reading' %}
<button>done reading</button> <button>done reading</button>
@ -21,7 +21,9 @@
{% for book in recent_books %} {% for book in recent_books %}
<div class="book-preview"> <div class="book-preview">
<img class="cover" src="static/images/small.jpg"> <img class="cover" src="static/images/small.jpg">
<p class="title">{{ book.data.title }}</p> <p class="title">
<a href="{{ book.openlibrary_key }}">{{ book.data.title }}</a>
</p>
<p>by <a href="" class="author">{{ book.authors.first.data.name }}</a></p> <p>by <a href="" class="author">{{ book.authors.first.data.name }}</a></p>
{% if not book.user_shelves %} {% if not book.user_shelves %}
<form name="shelve" action="/shelve/{{ request.user.username }}_to-read/{{ book.id }}" method="post"> <form name="shelve" action="/shelve/{{ request.user.username }}_to-read/{{ book.id }}" method="post">
@ -32,35 +34,13 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% for activity in activities %}
<div class="update"> <div class="update">
<div class="user-preview"> <div class="user-preview">
<img class="user-pic" src="static/images/profile.jpg"> <img class="user-pic" src="static/images/profile.jpg">
<span><a href="" class="user">Mouse</a> is currently reading</span> <span><a href="" class="user">Mouse</a> did {{ activity.activity_type }} </span>
</div>
<div class="book-preview">
<img class="cover" src="static/images/med.jpg">
<p class="title">Moby Dick</p>
<p>by <a href="" class="author">Herman Melville</a></p>
<p>"Command the murderous chalices! Drink ye harpooners! Drink and swear, ye men that man the deathful whaleboat's bow -- Death to Moby Dick!" So Captain Ahab binds his crew to fulfil his obsession -- the destruction of the great white whale. Under his lordly but maniacal command the Pequod's commercial mission is perverted to one of vengeance...</p>
</div>
<div class="interact">
<span>⭐️ Like</span>
<span>💬 <input type="text"></input></span>
</div>
</div>
<div class="update">
<img class="user-pic" src="static/images/profile.jpg">
<span><a href="" class="user">Mouse</a> is currently reading</span>
<div class="book-preview">
<img class="cover" src="static/images/med.jpg">
<p class="title">Moby Dick</p>
<p>by <a href="" class="author">Herman Melville</a></p>
<p>"Command the murderous chalices! Drink ye harpooners! Drink and swear, ye men that man the deathful whaleboat's bow -- Death to Moby Dick!" So Captain Ahab binds his crew to fulfil his obsession -- the destruction of the great white whale. Under his lordly but maniacal command the Pequod's commercial mission is perverted to one of vengeance...</p>
</div>
<div class="interact">
<span>⭐️ Like</span>
<span>💬 <input type="text"></input></span>
</div> </div>
</div> </div>
{% endfor %}
</div> </div>
{% endblock %} {% endblock %}

View file

@ -23,6 +23,8 @@ urlpatterns = [
path('login/', views.user_login), path('login/', views.user_login),
path('logout/', views.user_logout), path('logout/', views.user_logout),
path('user/<str:username>', views.user_profile), path('user/<str:username>', views.user_profile),
path('book/<str:book_identifier>', views.book_page),
path('review/', views.review),
path('shelve/<str:shelf_id>/<int:book_id>', views.shelve), path('shelve/<str:shelf_id>/<int:book_id>', views.shelve),
path('follow/', views.follow), path('follow/', views.follow),
path('unfollow/', views.unfollow), path('unfollow/', views.unfollow),

View file

@ -1,13 +1,12 @@
''' application views/pages ''' ''' application views/pages '''
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth import authenticate, login, logout from django.contrib.auth import authenticate, login, logout
from django.db.models import FilteredRelation, Q from django.db.models import Avg, FilteredRelation, Q
from django.shortcuts import redirect from django.shortcuts import redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from fedireads import models from fedireads import models, openlibrary
import fedireads.activitypub_templates as templates from fedireads import federation as api
from fedireads.federation import broadcast_activity, broadcast_follow
@login_required @login_required
def home(request): def home(request):
@ -20,14 +19,24 @@ def home(request):
'shelves', 'shelves',
condition=Q(shelves__user_id=request.user.id) condition=Q(shelves__user_id=request.user.id)
) )
).values('id', 'authors', 'data', 'user_shelves') ).values('id', 'authors', 'data', 'user_shelves', 'openlibrary_key')
following = models.User.objects.filter(
Q(followers=request.user) | Q(id=request.user.id))
activities = models.Activity.objects.filter(
user__in=following
).order_by('-created_date')[:10]
data = { data = {
'user': request.user, 'user': request.user,
'shelves': shelves, 'shelves': shelves,
'recent_books': recent_books, 'recent_books': recent_books,
'activities': activities,
} }
return TemplateResponse(request, 'feed.html', data) return TemplateResponse(request, 'feed.html', data)
@csrf_exempt @csrf_exempt
def user_login(request): def user_login(request):
''' authentication ''' ''' authentication '''
@ -44,6 +53,7 @@ def user_login(request):
return redirect(request.GET.get('next', '/')) return redirect(request.GET.get('next', '/'))
return TemplateResponse(request, 'login.html') return TemplateResponse(request, 'login.html')
@csrf_exempt @csrf_exempt
@login_required @login_required
def user_logout(request): def user_logout(request):
@ -65,30 +75,43 @@ def user_profile(request, username):
return TemplateResponse(request, 'user.html', data) return TemplateResponse(request, 'user.html', data)
@login_required
def book_page(request, book_identifier):
''' info about a book '''
book = openlibrary.get_or_create_book('/book/' + book_identifier)
reviews = models.Review.objects.filter(
Q(work=book.works.first()) | Q(book=book)
)
rating = reviews.aggregate(Avg('rating'))
data = {
'book': book,
'reviews': reviews,
'rating': rating['rating__avg'],
}
return TemplateResponse(request, 'book.html', data)
@csrf_exempt @csrf_exempt
@login_required @login_required
def shelve(request, shelf_id, book_id): def shelve(request, shelf_id, book_id):
''' put a book on a user's shelf ''' ''' put a book on a user's shelf '''
book = models.Book.objects.get(id=book_id) book = models.Book.objects.get(id=book_id)
shelf = models.Shelf.objects.get(identifier=shelf_id) shelf = models.Shelf.objects.get(identifier=shelf_id)
api.handle_shelve(request.user, book, shelf)
# update the database
models.ShelfBook(book=book, shelf=shelf, added_by=request.user).save()
# send out the activitypub action
summary = '%s marked %s as %s' % (
request.user.username,
book.data['title'],
shelf.name
)
obj = templates.note_object(request.user, summary)
#activity = templates.shelve_activity(request.user, book, shelf)
recipients = [templates.inbox(u) for u in request.user.followers.all()]
broadcast_activity(request.user, obj, recipients)
return redirect('/') return redirect('/')
@csrf_exempt
@login_required
def review(request):
''' create a book review note '''
book_identifier = request.POST.get('book')
book = openlibrary.get_or_create_book(book_identifier)
name = request.POST.get('name')
content = request.POST.get('content')
rating = request.POST.get('rating')
api.handle_review(request.user, book, name, content, rating)
return redirect(book_identifier)
@csrf_exempt @csrf_exempt
@login_required @login_required
@ -97,12 +120,10 @@ def follow(request):
to_follow = request.POST.get('user') to_follow = request.POST.get('user')
to_follow = models.User.objects.get(id=to_follow) to_follow = models.User.objects.get(id=to_follow)
activity = templates.follow_request(request.user, to_follow.actor) api.handle_outgoing_follow(request.user, to_follow)
broadcast_follow(request.user, activity, templates.inbox(to_follow))
return redirect('/user/%s' % to_follow.username) return redirect('/user/%s' % to_follow.username)
@csrf_exempt @csrf_exempt
@login_required @login_required
def unfollow(request): def unfollow(request):