mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-26 03:21:05 +00:00
Reviews
This commit is contained in:
parent
0345a5d9ff
commit
7ee59c5fd5
10 changed files with 407 additions and 256 deletions
|
@ -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)
|
|
@ -9,10 +9,9 @@ from django.http import HttpResponse, HttpResponseBadRequest, \
|
|||
from django.views.decorators.csrf import csrf_exempt
|
||||
from fedireads.settings import DOMAIN
|
||||
from fedireads import models
|
||||
from fedireads import openlibrary
|
||||
import fedireads.activitypub_templates as templates
|
||||
import json
|
||||
import requests
|
||||
from uuid import uuid4
|
||||
|
||||
def webfinger(request):
|
||||
''' 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()
|
||||
if not user:
|
||||
return HttpResponseNotFound('No account found')
|
||||
return JsonResponse(format_webfinger(user))
|
||||
|
||||
|
||||
def format_webfinger(user):
|
||||
''' helper function to create structured webfinger json '''
|
||||
return {
|
||||
return JsonResponse({
|
||||
'subject': 'acct:%s' % (user.full_username),
|
||||
'links': [
|
||||
{
|
||||
|
@ -37,21 +31,40 @@ def format_webfinger(user):
|
|||
'href': user.actor
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def get_actor(request, username):
|
||||
''' return an activitypub actor object '''
|
||||
if request.method != 'GET':
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
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
|
||||
def inbox(request, username):
|
||||
''' incoming activitypub events '''
|
||||
if request.method == 'GET':
|
||||
# return a collection of something?
|
||||
# TODO: return a collection of something?
|
||||
return JsonResponse({})
|
||||
|
||||
# TODO: RSA key verification
|
||||
|
@ -60,30 +73,47 @@ def inbox(request, username):
|
|||
activity = json.loads(request.body)
|
||||
except json.decoder.JSONDecodeError:
|
||||
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':
|
||||
handle_add(activity)
|
||||
return handle_add(activity)
|
||||
|
||||
if activity['type'] == 'Follow':
|
||||
response = handle_follow(activity)
|
||||
return JsonResponse(response)
|
||||
return handle_incoming_follow(activity)
|
||||
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
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 = 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_follow(activity):
|
||||
def handle_incoming_follow(activity):
|
||||
'''
|
||||
{
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
|
@ -99,42 +129,160 @@ def handle_follow(activity):
|
|||
# figure out who they are
|
||||
user = get_or_create_remote_user(activity)
|
||||
following.followers.add(user)
|
||||
# accept the request
|
||||
return templates.accept_follow(activity, following)
|
||||
# verify uuid and accept the request
|
||||
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
|
||||
def outbox(request, username):
|
||||
''' outbox for the requested user '''
|
||||
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':
|
||||
# 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')
|
||||
if data.activity.type == 'Follow':
|
||||
handle_follow(data)
|
||||
#data = request.body.decode('utf-8')
|
||||
return HttpResponse()
|
||||
|
||||
|
||||
def broadcast_activity(sender, obj, 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):
|
||||
def broadcast(sender, action, recipients):
|
||||
''' 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)
|
||||
now = datetime.utcnow().isoformat()
|
||||
message_to_sign = '''(request-target): post %s
|
||||
|
@ -175,3 +323,6 @@ def get_or_create_remote_user(activity):
|
|||
return user
|
||||
|
||||
|
||||
def format_inbox(user):
|
||||
''' describe an inbox '''
|
||||
return '%s/inbox' % (user.actor)
|
||||
|
|
|
@ -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
|
||||
import django.contrib.auth.models
|
||||
|
@ -14,7 +14,7 @@ class Migration(migrations.Migration):
|
|||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0009_alter_user_last_name_max_length'),
|
||||
('auth', '0011_update_proxy_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
@ -53,11 +53,23 @@ class Migration(migrations.Migration):
|
|||
('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(
|
||||
name='Author',
|
||||
fields=[
|
||||
('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()),
|
||||
('added_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
|
@ -68,7 +80,7 @@ class Migration(migrations.Migration):
|
|||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('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()),
|
||||
('added_date', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_date', models.DateTimeField(auto_now=True)),
|
||||
|
@ -76,16 +88,6 @@ class Migration(migrations.Migration):
|
|||
('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(
|
||||
name='Shelf',
|
||||
fields=[
|
||||
|
@ -99,6 +101,23 @@ class Migration(migrations.Migration):
|
|||
('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(
|
||||
name='ShelfBook',
|
||||
fields=[
|
||||
|
@ -108,16 +127,9 @@ class Migration(migrations.Migration):
|
|||
('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')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('book', 'shelf')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='shelf',
|
||||
|
@ -139,12 +151,37 @@ class Migration(migrations.Migration):
|
|||
name='works',
|
||||
field=models.ManyToManyField(to='fedireads.Work'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='shelfbook',
|
||||
unique_together={('book', 'shelf')},
|
||||
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(
|
||||
name='shelf',
|
||||
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',),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -10,15 +10,21 @@ import re
|
|||
|
||||
class User(AbstractUser):
|
||||
''' 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)
|
||||
public_key = models.TextField(blank=True, null=True)
|
||||
api_key = models.CharField(max_length=255, blank=True, null=True)
|
||||
actor = models.CharField(max_length=255)
|
||||
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)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
followers = models.ManyToManyField('self', symmetrical=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# give a new user keys
|
||||
|
@ -40,8 +46,9 @@ class User(AbstractUser):
|
|||
def execute_after_save(sender, instance, created, *args, **kwargs):
|
||||
''' create shelves for new users '''
|
||||
# TODO: how are remote users handled? what if they aren't readers?
|
||||
if not created:
|
||||
if not instance.local or not created:
|
||||
return
|
||||
|
||||
shelves = [{
|
||||
'name': 'To Read',
|
||||
'type': 'to-read',
|
||||
|
@ -62,14 +69,58 @@ def execute_after_save(sender, instance, created, *args, **kwargs):
|
|||
).save()
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
''' any kind of user post, incl. reviews, replies, and status updates '''
|
||||
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)
|
||||
activity_type = models.CharField(max_length=255)
|
||||
created_date = models.DateTimeField(auto_now_add=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):
|
||||
activitypub_id = 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()
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
|
@ -111,6 +163,7 @@ class ShelfBook(models.Model):
|
|||
on_delete=models.PROTECT
|
||||
)
|
||||
added_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ('book', 'shelf')
|
||||
|
||||
|
@ -118,7 +171,7 @@ class ShelfBook(models.Model):
|
|||
class Book(models.Model):
|
||||
''' a non-canonical copy from open library '''
|
||||
activitypub_id = models.CharField(max_length=255)
|
||||
openlibary_key = models.CharField(max_length=255)
|
||||
openlibrary_key = models.CharField(max_length=255)
|
||||
data = JSONField()
|
||||
works = models.ManyToManyField('Work')
|
||||
authors = models.ManyToManyField('Author')
|
||||
|
@ -138,19 +191,20 @@ class Book(models.Model):
|
|||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class Work(models.Model):
|
||||
''' encompassses all editions of a book '''
|
||||
openlibary_key = models.CharField(max_length=255)
|
||||
openlibrary_key = models.CharField(max_length=255)
|
||||
data = JSONField()
|
||||
added_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
class Author(models.Model):
|
||||
openlibary_key = models.CharField(max_length=255)
|
||||
openlibrary_key = models.CharField(max_length=255)
|
||||
data = JSONField()
|
||||
added_date = models.DateTimeField(auto_now_add=True)
|
||||
updated_date = models.DateTimeField(auto_now=True)
|
||||
|
|
|
@ -12,11 +12,11 @@ def get_or_create_book(olkey, user=None, update=True):
|
|||
|
||||
# get the existing entry from our db, if it exists
|
||||
try:
|
||||
book = Book.objects.get(openlibary_key=olkey)
|
||||
book = Book.objects.get(openlibrary_key=olkey)
|
||||
if not update:
|
||||
return book
|
||||
except ObjectDoesNotExist:
|
||||
book = Book(openlibary_key=olkey)
|
||||
book = Book(openlibrary_key=olkey)
|
||||
data = response.json()
|
||||
book.data = data
|
||||
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):
|
||||
''' load em up '''
|
||||
try:
|
||||
work = Work.objects.get(openlibary_key=olkey)
|
||||
work = Work.objects.get(openlibrary_key=olkey)
|
||||
except ObjectDoesNotExist:
|
||||
response = requests.get(OL_URL + olkey + '.json')
|
||||
data = response.json()
|
||||
work = Work(openlibary_key=olkey, data=data)
|
||||
work = Work(openlibrary_key=olkey, data=data)
|
||||
work.save()
|
||||
return work
|
||||
|
||||
def get_or_create_author(olkey):
|
||||
''' load that author '''
|
||||
try:
|
||||
author = Author.objects.get(openlibary_key=olkey)
|
||||
author = Author.objects.get(openlibrary_key=olkey)
|
||||
except ObjectDoesNotExist:
|
||||
response = requests.get(OL_URL + olkey + '.json')
|
||||
data = response.json()
|
||||
author = Author(openlibary_key=olkey, data=data)
|
||||
author = Author(openlibrary_key=olkey, data=data)
|
||||
author.save()
|
||||
return author
|
||||
|
||||
|
|
|
@ -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!
|
||||
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'
|
||||
|
||||
# Application definition
|
||||
|
|
27
fedireads/templates/book.html
Normal file
27
fedireads/templates/book.html
Normal 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 %}
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
{% for book in shelf.books.all %}
|
||||
<div class="book-preview">
|
||||
<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>
|
||||
{% if shelf.type == 'reading' %}
|
||||
<button>done reading</button>
|
||||
|
@ -21,7 +21,9 @@
|
|||
{% for book in recent_books %}
|
||||
<div class="book-preview">
|
||||
<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>
|
||||
{% if not book.user_shelves %}
|
||||
<form name="shelve" action="/shelve/{{ request.user.username }}_to-read/{{ book.id }}" method="post">
|
||||
|
@ -32,35 +34,13 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for activity in activities %}
|
||||
<div class="update">
|
||||
<div class="user-preview">
|
||||
<img class="user-pic" src="static/images/profile.jpg">
|
||||
<span><a href="" class="user">Mouse</a> is currently reading</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>
|
||||
<span><a href="" class="user">Mouse</a> did {{ activity.activity_type }} </span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -23,6 +23,8 @@ urlpatterns = [
|
|||
path('login/', views.user_login),
|
||||
path('logout/', views.user_logout),
|
||||
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('follow/', views.follow),
|
||||
path('unfollow/', views.unfollow),
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
''' application views/pages '''
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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.template.response import TemplateResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from fedireads import models
|
||||
import fedireads.activitypub_templates as templates
|
||||
from fedireads.federation import broadcast_activity, broadcast_follow
|
||||
from fedireads import models, openlibrary
|
||||
from fedireads import federation as api
|
||||
|
||||
@login_required
|
||||
def home(request):
|
||||
|
@ -20,14 +19,24 @@ def home(request):
|
|||
'shelves',
|
||||
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 = {
|
||||
'user': request.user,
|
||||
'shelves': shelves,
|
||||
'recent_books': recent_books,
|
||||
'activities': activities,
|
||||
}
|
||||
return TemplateResponse(request, 'feed.html', data)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def user_login(request):
|
||||
''' authentication '''
|
||||
|
@ -44,6 +53,7 @@ def user_login(request):
|
|||
return redirect(request.GET.get('next', '/'))
|
||||
return TemplateResponse(request, 'login.html')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def user_logout(request):
|
||||
|
@ -65,30 +75,43 @@ def user_profile(request, username):
|
|||
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
|
||||
@login_required
|
||||
def shelve(request, shelf_id, book_id):
|
||||
''' put a book on a user's shelf '''
|
||||
book = models.Book.objects.get(id=book_id)
|
||||
shelf = models.Shelf.objects.get(identifier=shelf_id)
|
||||
|
||||
# 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)
|
||||
|
||||
api.handle_shelve(request.user, book, shelf)
|
||||
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
|
||||
@login_required
|
||||
|
@ -97,12 +120,10 @@ def follow(request):
|
|||
to_follow = request.POST.get('user')
|
||||
to_follow = models.User.objects.get(id=to_follow)
|
||||
|
||||
activity = templates.follow_request(request.user, to_follow.actor)
|
||||
broadcast_follow(request.user, activity, templates.inbox(to_follow))
|
||||
api.handle_outgoing_follow(request.user, to_follow)
|
||||
return redirect('/user/%s' % to_follow.username)
|
||||
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@login_required
|
||||
def unfollow(request):
|
||||
|
|
Loading…
Reference in a new issue