diff --git a/fedireads/activitypub_templates.py b/fedireads/activitypub_templates.py deleted file mode 100644 index 750dd0606..000000000 --- a/fedireads/activitypub_templates.py +++ /dev/null @@ -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) diff --git a/fedireads/federation.py b/fedireads/federation.py index b118b0542..79eb2178a 100644 --- a/fedireads/federation.py +++ b/fedireads/federation.py @@ -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) diff --git a/fedireads/migrations/0001_initial.py b/fedireads/migrations/0001_initial.py index cd0592fc9..1724496b2 100644 --- a/fedireads/migrations/0001_initial.py +++ b/fedireads/migrations/0001_initial.py @@ -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',), + ), ] diff --git a/fedireads/models.py b/fedireads/models.py index f1cb6be2a..41710e841 100644 --- a/fedireads/models.py +++ b/fedireads/models.py @@ -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) diff --git a/fedireads/openlibrary.py b/fedireads/openlibrary.py index 64f0401da..2a52cb194 100644 --- a/fedireads/openlibrary.py +++ b/fedireads/openlibrary.py @@ -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 diff --git a/fedireads/settings.py b/fedireads/settings.py index 672772f1c..568f057a9 100644 --- a/fedireads/settings.py +++ b/fedireads/settings.py @@ -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 diff --git a/fedireads/templates/book.html b/fedireads/templates/book.html new file mode 100644 index 000000000..aabe0730d --- /dev/null +++ b/fedireads/templates/book.html @@ -0,0 +1,27 @@ +{% extends 'layout.html' %} +{% block content %} +
+
+ +

{{ book.data.title }}

+ by {{ book.authors.first.data.name }} + {{ rating }} stars +
+
+ + + + + +
+ +
+

Reviews

+ {% for review in reviews %} +

{{ review.name }}{{ review.rating }} stars, by {{ review.user.username }}

+

{{ review.review_content }}

+ {% endfor %} +
+
+{% endblock %} + diff --git a/fedireads/templates/feed.html b/fedireads/templates/feed.html index 63fa70796..9c7b56915 100644 --- a/fedireads/templates/feed.html +++ b/fedireads/templates/feed.html @@ -6,7 +6,7 @@ {% for book in shelf.books.all %}
-

{{ book.data.title }}

+

{{ book.data.title }}

by {{ book.authors.first.data.name }}

{% if shelf.type == 'reading' %} @@ -21,7 +21,9 @@ {% for book in recent_books %}
-

{{ book.data.title }}

+

+ {{ book.data.title }} +

by {{ book.authors.first.data.name }}

{% if not book.user_shelves %}
@@ -32,35 +34,13 @@
{% endfor %}
+ {% for activity in activities %}
- Mouse is currently reading -
-
- -

Moby Dick

-

by Herman Melville

-

"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...

-
-
- ⭐️ Like - 💬 -
-
-
- - Mouse is currently reading -
- -

Moby Dick

-

by Herman Melville

-

"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...

-
-
- ⭐️ Like - 💬 + Mouse did {{ activity.activity_type }}
+ {% endfor %} {% endblock %} diff --git a/fedireads/urls.py b/fedireads/urls.py index f794350cc..db103a8ba 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -23,6 +23,8 @@ urlpatterns = [ path('login/', views.user_login), path('logout/', views.user_logout), path('user/', views.user_profile), + path('book/', views.book_page), + path('review/', views.review), path('shelve//', views.shelve), path('follow/', views.follow), path('unfollow/', views.unfollow), diff --git a/fedireads/views.py b/fedireads/views.py index 7445b14a7..c652493c4 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -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):