From ee7c04cc7e442bdba54e5c9d92a135e013315803 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 14:26:27 -0700 Subject: [PATCH 01/22] code style cleanup --- fedireads/activitypub/shelve.py | 2 +- fedireads/broadcast.py | 9 +++- fedireads/forms.py | 2 +- fedireads/goodreads_import.py | 5 +++ fedireads/incoming.py | 73 +++++++++++++++------------------ fedireads/models/import_job.py | 4 +- fedireads/view_actions.py | 1 - fedireads/wellknown.py | 26 ++++++------ 8 files changed, 65 insertions(+), 57 deletions(-) diff --git a/fedireads/activitypub/shelve.py b/fedireads/activitypub/shelve.py index d3a22edf..46cf4132 100644 --- a/fedireads/activitypub/shelve.py +++ b/fedireads/activitypub/shelve.py @@ -12,7 +12,7 @@ def get_remove(*args): def get_add_remove(user, book, shelf, action='Add'): - ''' format an Add or Remove json blob ''' + ''' format a shelve book json blob ''' uuid = uuid4() return { '@context': 'https://www.w3.org/ns/activitystreams', diff --git a/fedireads/broadcast.py b/fedireads/broadcast.py index 995df2cf..6501137e 100644 --- a/fedireads/broadcast.py +++ b/fedireads/broadcast.py @@ -19,10 +19,13 @@ def get_public_recipients(user, software=None): # TODO: eventually we may want to handle particular software differently followers = followers.filter(fedireads_user=(software == 'fedireads')) + # we want shared inboxes when available shared = followers.filter( shared_inbox__isnull=False ).values_list('shared_inbox', flat=True).distinct() + # if a user doesn't have a shared inbox, we need their personal inbox + # iirc pixelfed doesn't have shared inboxes inboxes = followers.filter( shared_inbox__isnull=True ).values_list('inbox', flat=True) @@ -33,7 +36,9 @@ def get_public_recipients(user, software=None): def broadcast(sender, activity, software=None, \ privacy='public', direct_recipients=None): ''' send out an event ''' + # start with parsing the direct recipients recipients = [u.inbox for u in direct_recipients or []] + # and then add any other recipients # TODO: other kinds of privacy if privacy == 'public': recipients += get_public_recipients(sender, software=software) @@ -69,7 +74,9 @@ def sign_and_send(sender, activity, destination): ] message_to_sign = '\n'.join(signature_headers) - # TODO: raise an error if the user doesn't have a private key + if not sender.private_key: + # this shouldn't happen. it would be bad if it happened. + raise ValueError('No private key found for sender') signer = pkcs1_15.new(RSA.import_key(sender.private_key)) signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8'))) diff --git a/fedireads/forms.py b/fedireads/forms.py index a7501bf5..060658c7 100644 --- a/fedireads/forms.py +++ b/fedireads/forms.py @@ -101,7 +101,7 @@ class EditionForm(ModelForm): 'updated_date', 'last_sync_date', - 'authors', + 'authors',# TODO 'parent_work', 'shelves', 'misc_identifiers', diff --git a/fedireads/goodreads_import.py b/fedireads/goodreads_import.py index fae01d9c..ff7f9429 100644 --- a/fedireads/goodreads_import.py +++ b/fedireads/goodreads_import.py @@ -12,6 +12,7 @@ MAX_ENTRIES = 500 def create_job(user, csv_file): + ''' check over a csv and creates a database entry for the job''' job = ImportJob.objects.create(user=user) for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]): if not all(x in entry for x in ('ISBN13', 'Title', 'Author')): @@ -19,13 +20,17 @@ def create_job(user, csv_file): ImportItem(job=job, index=index, data=entry).save() return job + def start_import(job): + ''' initalizes a csv import job ''' result = import_data.delay(job.id) job.task_id = result.id job.save() + @app.task def import_data(job_id): + ''' does the actual lookup work in a celery task ''' job = ImportJob.objects.get(id=job_id) try: results = [] diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 62fd3d43..206da9d4 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -42,6 +42,9 @@ def shared_inbox(request): except json.decoder.JSONDecodeError: return HttpResponseBadRequest() + if not activity.get('object'): + return HttpResponseBadRequest() + try: verify_signature(request) except ValueError: @@ -128,9 +131,13 @@ def verify_signature(request): @app.task def handle_follow(activity): ''' someone wants to follow a local user ''' - # figure out who they want to follow - to_follow = models.User.objects.get(actor=activity['object']) - # figure out who they are + # figure out who they want to follow -- not using get_or_create because + # we only allow you to follow local users + try: + to_follow = models.User.objects.get(actor=activity['object']) + except models.User.DoesNotExist: + return False + # figure out who the actor is user = get_or_create_remote_user(activity['actor']) try: request = models.UserFollowRequest.objects.create( @@ -165,14 +172,11 @@ def handle_follow(activity): def handle_unfollow(activity): ''' unfollow a local user ''' obj = activity['object'] - if not obj['type'] == 'Follow': - #idk how to undo other things - return HttpResponseNotFound() try: requester = get_or_create_remote_user(obj['actor']) to_unfollow = models.User.objects.get(actor=obj['object']) except models.User.DoesNotExist: - return HttpResponseNotFound() + return False to_unfollow.followers.remove(requester) @@ -209,7 +213,7 @@ def handle_follow_reject(activity): ) request.delete() except models.UserFollowRequest.DoesNotExist: - pass + return False @app.task @@ -217,46 +221,37 @@ def handle_create(activity): ''' someone did something, good on them ''' user = get_or_create_remote_user(activity['actor']) - if not 'object' in activity: - return False - if user.local: # we really oughtn't even be sending in this case return True if activity['object'].get('fedireadsType') and \ 'inReplyToBook' in activity['object']: - try: - if activity['object']['fedireadsType'] == 'Review': - builder = status_builder.create_review_from_activity - elif activity['object']['fedireadsType'] == 'Quotation': - builder = status_builder.create_quotation_from_activity - else: - builder = status_builder.create_comment_from_activity + if activity['object']['fedireadsType'] == 'Review': + builder = status_builder.create_review_from_activity + elif activity['object']['fedireadsType'] == 'Quotation': + builder = status_builder.create_quotation_from_activity + else: + builder = status_builder.create_comment_from_activity - # create the status, it'll throw a valueerror if anything is missing - builder(user, activity['object']) - except ValueError: - return False + # create the status, it'll throw a ValueError if anything is missing + builder(user, activity['object']) elif activity['object'].get('inReplyTo'): # only create the status if it's in reply to a status we already know if not status_builder.get_status(activity['object']['inReplyTo']): return True - try: - status = status_builder.create_status_from_activity( - user, - activity['object'] + status = status_builder.create_status_from_activity( + user, + activity['object'] + ) + if status and status.reply_parent: + status_builder.create_notification( + status.reply_parent.user, + 'REPLY', + related_user=status.user, + related_status=status, ) - if status and status.reply_parent: - status_builder.create_notification( - status.reply_parent.user, - 'REPLY', - related_user=status.user, - related_status=status, - ) - except ValueError: - return False return True @@ -268,7 +263,7 @@ def handle_favorite(activity): status = models.Status.objects.get(id=status_id) liker = get_or_create_remote_user(activity['actor']) except (models.Status.DoesNotExist, models.User.DoesNotExist): - return + return False if not liker.local: status_builder.create_favorite_from_activity(liker, activity) @@ -287,7 +282,7 @@ def handle_unfavorite(activity): favorite_id = activity['object']['id'] fav = status_builder.get_favorite(favorite_id) if not fav: - return HttpResponseNotFound() + return False fav.delete() @@ -300,7 +295,7 @@ def handle_boost(activity): status = models.Status.objects.get(id=status_id) booster = get_or_create_remote_user(activity['actor']) except (models.Status.DoesNotExist, models.User.DoesNotExist): - return HttpResponseNotFound() + return False if not booster.local: status_builder.create_boost_from_activity(booster, activity) @@ -318,7 +313,7 @@ def handle_tag(activity): ''' someone is tagging or shelving a book ''' user = get_or_create_remote_user(activity['actor']) if not user.local: - book = activity['target']['id'].split('/')[-1] + book = activity['target']['id'] status_builder.create_tag(user, book, activity['object']['name']) diff --git a/fedireads/models/import_job.py b/fedireads/models/import_job.py index 9836bea9..63976af2 100644 --- a/fedireads/models/import_job.py +++ b/fedireads/models/import_job.py @@ -1,3 +1,4 @@ +''' track progress of goodreads imports ''' import re import dateutil.parser @@ -5,7 +6,7 @@ from django.db import models from django.utils import timezone from fedireads import books_manager -from fedireads.models import Edition, ReadThrough, User, Book +from fedireads.models import ReadThrough, User, Book from fedireads.utils.fields import JSONField # Mapping goodreads -> fedireads shelf titles. @@ -32,6 +33,7 @@ def construct_search_term(title, author): return ' '.join([title, author]) + class ImportJob(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) created_date = models.DateTimeField(default=timezone.now) diff --git a/fedireads/view_actions.py b/fedireads/view_actions.py index eaa49304..6c33e7c2 100644 --- a/fedireads/view_actions.py +++ b/fedireads/view_actions.py @@ -13,7 +13,6 @@ from fedireads import forms, models, outgoing from fedireads import goodreads_import from fedireads.settings import DOMAIN from fedireads.views import get_user_from_username -from fedireads.books_manager import get_or_create_book def user_login(request): diff --git a/fedireads/wellknown.py b/fedireads/wellknown.py index 9e5f3f31..4f2da723 100644 --- a/fedireads/wellknown.py +++ b/fedireads/wellknown.py @@ -53,23 +53,23 @@ def nodeinfo(request): status_count = models.Status.objects.filter(user__local=True).count() user_count = models.User.objects.count() return JsonResponse({ - "version": "2.0", - "software": { - "name": "fedireads", - "version": "0.0.1" + 'version': '2.0', + 'software': { + 'name': 'fedireads', + 'version': '0.0.1' }, - "protocols": [ - "activitypub" + 'protocols': [ + 'activitypub' ], - "usage": { - "users": { - "total": user_count, - "activeMonth": user_count, # TODO - "activeHalfyear": user_count, # TODO + 'usage': { + 'users': { + 'total': user_count, + 'activeMonth': user_count, # TODO + 'activeHalfyear': user_count, # TODO }, - "localPosts": status_count, + 'localPosts': status_count, }, - "openRegistrations": True, + 'openRegistrations': True, }) From 93e53d3cca42d0c6648200b39b26e8fbcb99e843 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 16:16:28 -0700 Subject: [PATCH 02/22] Adds some model unit tests --- fedireads/tests/__init__.py | 1 + fedireads/tests/test_models.py | 145 +++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 fedireads/tests/__init__.py create mode 100644 fedireads/tests/test_models.py diff --git a/fedireads/tests/__init__.py b/fedireads/tests/__init__.py new file mode 100644 index 00000000..b6e690fd --- /dev/null +++ b/fedireads/tests/__init__.py @@ -0,0 +1 @@ +from . import * diff --git a/fedireads/tests/test_models.py b/fedireads/tests/test_models.py new file mode 100644 index 00000000..3296e456 --- /dev/null +++ b/fedireads/tests/test_models.py @@ -0,0 +1,145 @@ +''' testing models ''' +import datetime + +from django.test import TestCase + +from fedireads import models, settings + + +class Book(TestCase): + ''' not too much going on in the books model but here we are ''' + def setUp(self): + work = models.Work.objects.create(title='Example Work') + models.Edition.objects.create(title='Example Edition', parent_work=work) + + def test_absolute_id(self): + ''' editions and works use the same absolute id syntax ''' + book = models.Edition.objects.first() + expected_id = 'https://%s/book/%d' % (settings.DOMAIN, book.id) + self.assertEqual(book.absolute_id, expected_id) + + def test_create_book(self): + ''' you shouldn't be able to create Books (only editions and works) ''' + self.assertRaises( + ValueError, + models.Book.objects.create, + title='Invalid Book' + ) + + def test_default_edition(self): + ''' a work should always be able to produce a deafult edition ''' + default_edition = models.Work.objects.first().default_edition + self.assertIsInstance(default_edition, models.Edition) + + +class ImportJob(TestCase): + ''' this is a fancy one!!! ''' + def setUp(self): + ''' data is from a goodreads export of The Raven Tower ''' + read_data = { + 'Book Id': 39395857, + 'Title': 'The Raven Tower', + 'Author': 'Ann Leckie', + 'Author l-f': 'Leckie, Ann', + 'Additional Authors': '', + 'ISBN': '="0356506991"', + 'ISBN13': '="9780356506999"', + 'My Rating': 0, + 'Average Rating': 4.06, + 'Publisher': 'Orbit', + 'Binding': 'Hardcover', + 'Number of Pages': 416, + 'Year Published': 2019, + 'Original Publication Year': 2019, + 'Date Read': '2019/04/09', + 'Date Added': '2019/04/09', + 'Bookshelves': '', + 'Bookshelves with positions': '', + 'Exclusive Shelf': 'read', + 'My Review': '', + 'Spoiler': '', + 'Private Notes': '', + 'Read Count': 1, + 'Recommended For': '', + 'Recommended By': '', + 'Owned Copies': 0, + 'Original Purchase Date': '', + 'Original Purchase Location': '', + 'Condition': '', + 'Condition Description': '', + 'BCID': '' + } + currently_reading_data = read_data.copy() + currently_reading_data['Exclusive Shelf'] = 'currently-reading' + currently_reading_data['Date Read'] = '' + + unknown_read_data = currently_reading_data.copy() + unknown_read_data['Exclusive Shelf'] = 'read' + + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + job = models.ImportJob.objects.create(user=user) + models.ImportItem.objects.create( + job=job, index=1, data=currently_reading_data) + models.ImportItem.objects.create( + job=job, index=2, data=read_data) + models.ImportItem.objects.create( + job=job, index=3, data=unknown_read_data) + + + def test_isbn(self): + ''' it unquotes the isbn13 field from data ''' + expected = '9780356506999' + item = models.ImportItem.objects.get(index=1) + self.assertEqual(item.isbn, expected) + + + def test_shelf(self): + ''' converts to the local shelf typology ''' + expected = 'reading' + item = models.ImportItem.objects.get(index=1) + self.assertEqual(item.shelf, expected) + + + def test_date_added(self): + ''' converts to the local shelf typology ''' + expected = datetime.datetime(2019, 4, 9, 0, 0) + item = models.ImportItem.objects.get(index=1) + self.assertEqual(item.date_added, expected) + + + def test_date_read(self): + ''' converts to the local shelf typology ''' + expected = datetime.datetime(2019, 4, 9, 0, 0) + item = models.ImportItem.objects.get(index=2) + self.assertEqual(item.date_read, expected) + + + def test_reads(self): + ''' various states of reading ''' + expected_current = [models.ReadThrough( + start_date=datetime.datetime(2019, 4, 9, 0, 0), + finish_date=None + )] + expected_read = [models.ReadThrough( + start_date=datetime.datetime(2019, 4, 9, 0, 0), + finish_date=datetime.datetime(2019, 4, 9, 0, 0), + )] + expected_unknown = [models.ReadThrough( + start_date=None, + finish_date=None + )] + expecteds = [expected_current, expected_read, expected_unknown] + + actuals = [ + models.ImportItem.objects.get(index=1), + models.ImportItem.objects.get(index=2), + models.ImportItem.objects.get(index=3), + ] + for (expected, actual) in zip(expecteds, actuals): + actual = actual.reads + + self.assertIsInstance(actual, list) + self.assertIsInstance(actual, models.ReadThrough) + self.assertEqual(actual[0].start_date, expected[0].start_date) + self.assertEqual(actual[0].finish_date, expected[0].finish_date) From 49cdd5388fb505dd196458fdb8f72fb6c0fc86dd Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 16:38:05 -0700 Subject: [PATCH 03/22] shelf model unit test also fixes my bad logic in the import job tests --- fedireads/tests/test_models.py | 55 +++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 24 deletions(-) diff --git a/fedireads/tests/test_models.py b/fedireads/tests/test_models.py index 3296e456..a0372e6c 100644 --- a/fedireads/tests/test_models.py +++ b/fedireads/tests/test_models.py @@ -75,6 +75,7 @@ class ImportJob(TestCase): unknown_read_data = currently_reading_data.copy() unknown_read_data['Exclusive Shelf'] = 'read' + unknown_read_data['Date Read'] = '' user = models.User.objects.create_user( 'mouse', 'mouse@mouse.mouse', 'mouseword') @@ -117,29 +118,35 @@ class ImportJob(TestCase): def test_reads(self): ''' various states of reading ''' - expected_current = [models.ReadThrough( - start_date=datetime.datetime(2019, 4, 9, 0, 0), - finish_date=None - )] - expected_read = [models.ReadThrough( - start_date=datetime.datetime(2019, 4, 9, 0, 0), - finish_date=datetime.datetime(2019, 4, 9, 0, 0), - )] - expected_unknown = [models.ReadThrough( - start_date=None, - finish_date=None - )] - expecteds = [expected_current, expected_read, expected_unknown] + # currently reading + expected = [models.ReadThrough( + start_date=datetime.datetime(2019, 4, 9, 0, 0))] + actual = models.ImportItem.objects.get(index=1) + self.assertEqual(actual.reads[0].start_date, expected[0].start_date) + self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) - actuals = [ - models.ImportItem.objects.get(index=1), - models.ImportItem.objects.get(index=2), - models.ImportItem.objects.get(index=3), - ] - for (expected, actual) in zip(expecteds, actuals): - actual = actual.reads + # read + expected = [models.ReadThrough( + finish_date=datetime.datetime(2019, 4, 9, 0, 0))] + actual = models.ImportItem.objects.get(index=2) + self.assertEqual(actual.reads[0].start_date, expected[0].start_date) + self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) - self.assertIsInstance(actual, list) - self.assertIsInstance(actual, models.ReadThrough) - self.assertEqual(actual[0].start_date, expected[0].start_date) - self.assertEqual(actual[0].finish_date, expected[0].finish_date) + # unknown dates + expected = [] + actual = models.ImportItem.objects.get(index=3) + self.assertEqual(actual.reads, expected) + + +class Shelf(TestCase): + def setUp(self): + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + models.Shelf.objects.create( + name='Test Shelf', identifier='test-shelf', user=user) + + def test_absolute_id(self): + ''' editions and works use the same absolute id syntax ''' + shelf = models.Shelf.objects.get(identifier='test-shelf') + expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN + self.assertEqual(shelf.absolute_id, expected_id) From 4468933edd89f694d0cf3bffe1817e0d64681cc9 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 16:52:16 -0700 Subject: [PATCH 04/22] Adds status tests --- fedireads/tests/test_models.py | 46 ++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/fedireads/tests/test_models.py b/fedireads/tests/test_models.py index a0372e6c..5af338fa 100644 --- a/fedireads/tests/test_models.py +++ b/fedireads/tests/test_models.py @@ -150,3 +150,49 @@ class Shelf(TestCase): shelf = models.Shelf.objects.get(identifier='test-shelf') expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN self.assertEqual(shelf.absolute_id, expected_id) + + +class Status(TestCase): + def setUp(self): + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + book = models.Edition.objects.create(title='Example Edition') + + models.Status.objects.create(user=user, content='Blah blah') + models.Comment.objects.create(user=user, content='content', book=book) + models.Quotation.objects.create( + user=user, content='content', book=book, quote='blah') + models.Review.objects.create( + user=user, content='content', book=book, rating=3) + + def test_status(self): + status = models.Status.objects.first() + self.assertEqual(status.status_type, 'Note') + self.assertEqual(status.activity_type, 'Note') + expected_id = 'https://%s/user/mouse/status/%d' % \ + (settings.DOMAIN, status.id) + self.assertEqual(status.absolute_id, expected_id) + + def test_comment(self): + comment = models.Comment.objects.first() + self.assertEqual(comment.status_type, 'Comment') + self.assertEqual(comment.activity_type, 'Note') + expected_id = 'https://%s/user/mouse/comment/%d' % \ + (settings.DOMAIN, comment.id) + self.assertEqual(comment.absolute_id, expected_id) + + def test_quotation(self): + quotation = models.Quotation.objects.first() + self.assertEqual(quotation.status_type, 'Quotation') + self.assertEqual(quotation.activity_type, 'Note') + expected_id = 'https://%s/user/mouse/quotation/%d' % \ + (settings.DOMAIN, quotation.id) + self.assertEqual(quotation.absolute_id, expected_id) + + def test_review(self): + review = models.Review.objects.first() + self.assertEqual(review.status_type, 'Review') + self.assertEqual(review.activity_type, 'Article') + expected_id = 'https://%s/user/mouse/review/%d' % \ + (settings.DOMAIN, review.id) + self.assertEqual(review.absolute_id, expected_id) From 768ce1e5e79a9b86adb0d4aa566bdb192bfdce53 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 17:10:02 -0700 Subject: [PATCH 05/22] split tests into separate files --- fedireads/tests/test_book_model.py | 44 ++++++++++ .../{test_models.py => test_import_model.py} | 86 +------------------ fedireads/tests/test_status_model.py | 60 +++++++++++++ 3 files changed, 105 insertions(+), 85 deletions(-) create mode 100644 fedireads/tests/test_book_model.py rename fedireads/tests/{test_models.py => test_import_model.py} (52%) create mode 100644 fedireads/tests/test_status_model.py diff --git a/fedireads/tests/test_book_model.py b/fedireads/tests/test_book_model.py new file mode 100644 index 00000000..58cc8fc0 --- /dev/null +++ b/fedireads/tests/test_book_model.py @@ -0,0 +1,44 @@ +''' testing models ''' +from django.test import TestCase + +from fedireads import models, settings + + +class Book(TestCase): + ''' not too much going on in the books model but here we are ''' + def setUp(self): + work = models.Work.objects.create(title='Example Work') + models.Edition.objects.create(title='Example Edition', parent_work=work) + + def test_absolute_id(self): + ''' editions and works use the same absolute id syntax ''' + book = models.Edition.objects.first() + expected_id = 'https://%s/book/%d' % (settings.DOMAIN, book.id) + self.assertEqual(book.absolute_id, expected_id) + + def test_create_book(self): + ''' you shouldn't be able to create Books (only editions and works) ''' + self.assertRaises( + ValueError, + models.Book.objects.create, + title='Invalid Book' + ) + + def test_default_edition(self): + ''' a work should always be able to produce a deafult edition ''' + default_edition = models.Work.objects.first().default_edition + self.assertIsInstance(default_edition, models.Edition) + + +class Shelf(TestCase): + def setUp(self): + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + models.Shelf.objects.create( + name='Test Shelf', identifier='test-shelf', user=user) + + def test_absolute_id(self): + ''' editions and works use the same absolute id syntax ''' + shelf = models.Shelf.objects.get(identifier='test-shelf') + expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN + self.assertEqual(shelf.absolute_id, expected_id) diff --git a/fedireads/tests/test_models.py b/fedireads/tests/test_import_model.py similarity index 52% rename from fedireads/tests/test_models.py rename to fedireads/tests/test_import_model.py index 5af338fa..bcdcafb9 100644 --- a/fedireads/tests/test_models.py +++ b/fedireads/tests/test_import_model.py @@ -1,35 +1,8 @@ ''' testing models ''' import datetime - from django.test import TestCase -from fedireads import models, settings - - -class Book(TestCase): - ''' not too much going on in the books model but here we are ''' - def setUp(self): - work = models.Work.objects.create(title='Example Work') - models.Edition.objects.create(title='Example Edition', parent_work=work) - - def test_absolute_id(self): - ''' editions and works use the same absolute id syntax ''' - book = models.Edition.objects.first() - expected_id = 'https://%s/book/%d' % (settings.DOMAIN, book.id) - self.assertEqual(book.absolute_id, expected_id) - - def test_create_book(self): - ''' you shouldn't be able to create Books (only editions and works) ''' - self.assertRaises( - ValueError, - models.Book.objects.create, - title='Invalid Book' - ) - - def test_default_edition(self): - ''' a work should always be able to produce a deafult edition ''' - default_edition = models.Work.objects.first().default_edition - self.assertIsInstance(default_edition, models.Edition) +from fedireads import models class ImportJob(TestCase): @@ -138,61 +111,4 @@ class ImportJob(TestCase): self.assertEqual(actual.reads, expected) -class Shelf(TestCase): - def setUp(self): - user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') - models.Shelf.objects.create( - name='Test Shelf', identifier='test-shelf', user=user) - def test_absolute_id(self): - ''' editions and works use the same absolute id syntax ''' - shelf = models.Shelf.objects.get(identifier='test-shelf') - expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN - self.assertEqual(shelf.absolute_id, expected_id) - - -class Status(TestCase): - def setUp(self): - user = models.User.objects.create_user( - 'mouse', 'mouse@mouse.mouse', 'mouseword') - book = models.Edition.objects.create(title='Example Edition') - - models.Status.objects.create(user=user, content='Blah blah') - models.Comment.objects.create(user=user, content='content', book=book) - models.Quotation.objects.create( - user=user, content='content', book=book, quote='blah') - models.Review.objects.create( - user=user, content='content', book=book, rating=3) - - def test_status(self): - status = models.Status.objects.first() - self.assertEqual(status.status_type, 'Note') - self.assertEqual(status.activity_type, 'Note') - expected_id = 'https://%s/user/mouse/status/%d' % \ - (settings.DOMAIN, status.id) - self.assertEqual(status.absolute_id, expected_id) - - def test_comment(self): - comment = models.Comment.objects.first() - self.assertEqual(comment.status_type, 'Comment') - self.assertEqual(comment.activity_type, 'Note') - expected_id = 'https://%s/user/mouse/comment/%d' % \ - (settings.DOMAIN, comment.id) - self.assertEqual(comment.absolute_id, expected_id) - - def test_quotation(self): - quotation = models.Quotation.objects.first() - self.assertEqual(quotation.status_type, 'Quotation') - self.assertEqual(quotation.activity_type, 'Note') - expected_id = 'https://%s/user/mouse/quotation/%d' % \ - (settings.DOMAIN, quotation.id) - self.assertEqual(quotation.absolute_id, expected_id) - - def test_review(self): - review = models.Review.objects.first() - self.assertEqual(review.status_type, 'Review') - self.assertEqual(review.activity_type, 'Article') - expected_id = 'https://%s/user/mouse/review/%d' % \ - (settings.DOMAIN, review.id) - self.assertEqual(review.absolute_id, expected_id) diff --git a/fedireads/tests/test_status_model.py b/fedireads/tests/test_status_model.py new file mode 100644 index 00000000..6d221d62 --- /dev/null +++ b/fedireads/tests/test_status_model.py @@ -0,0 +1,60 @@ +''' testing models ''' +from django.test import TestCase + +from fedireads import models, settings + + +class Status(TestCase): + def setUp(self): + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + book = models.Edition.objects.create(title='Example Edition') + + models.Status.objects.create(user=user, content='Blah blah') + models.Comment.objects.create(user=user, content='content', book=book) + models.Quotation.objects.create( + user=user, content='content', book=book, quote='blah') + models.Review.objects.create( + user=user, content='content', book=book, rating=3) + + def test_status(self): + status = models.Status.objects.first() + self.assertEqual(status.status_type, 'Note') + self.assertEqual(status.activity_type, 'Note') + expected_id = 'https://%s/user/mouse/status/%d' % \ + (settings.DOMAIN, status.id) + self.assertEqual(status.absolute_id, expected_id) + + def test_comment(self): + comment = models.Comment.objects.first() + self.assertEqual(comment.status_type, 'Comment') + self.assertEqual(comment.activity_type, 'Note') + expected_id = 'https://%s/user/mouse/comment/%d' % \ + (settings.DOMAIN, comment.id) + self.assertEqual(comment.absolute_id, expected_id) + + def test_quotation(self): + quotation = models.Quotation.objects.first() + self.assertEqual(quotation.status_type, 'Quotation') + self.assertEqual(quotation.activity_type, 'Note') + expected_id = 'https://%s/user/mouse/quotation/%d' % \ + (settings.DOMAIN, quotation.id) + self.assertEqual(quotation.absolute_id, expected_id) + + def test_review(self): + review = models.Review.objects.first() + self.assertEqual(review.status_type, 'Review') + self.assertEqual(review.activity_type, 'Article') + expected_id = 'https://%s/user/mouse/review/%d' % \ + (settings.DOMAIN, review.id) + self.assertEqual(review.absolute_id, expected_id) + + +class Tag(TestCase): + def test_tag(self): + book = models.Edition.objects.create(title='Example Edition') + user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + tag = models.Tag.objects.create(user=user, book=book, name='t/est tag') + self.assertEqual(tag.identifier, 't%2Fest+tag') + From ab7acc6db63f50251e374563038e338bdaa50c7f Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 17:30:14 -0700 Subject: [PATCH 06/22] user model tests --- fedireads/tests/test_user_model.py | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 fedireads/tests/test_user_model.py diff --git a/fedireads/tests/test_user_model.py b/fedireads/tests/test_user_model.py new file mode 100644 index 00000000..c972bca0 --- /dev/null +++ b/fedireads/tests/test_user_model.py @@ -0,0 +1,35 @@ +''' testing models ''' +from django.test import TestCase + +from fedireads import models +from fedireads.settings import DOMAIN + + +class User(TestCase): + def setUp(self): + models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + + def test_computed_fields(self): + ''' username instead of id here ''' + user = models.User.objects.get(localname='mouse') + expected_id = 'https://%s/user/mouse' % DOMAIN + self.assertEqual(user.absolute_id, expected_id) + self.assertEqual(user.username, 'mouse@%s' % DOMAIN) + self.assertEqual(user.localname, 'mouse') + self.assertEqual(user.actor, 'https://%s/user/mouse' % DOMAIN) + self.assertEqual(user.shared_inbox, 'https://%s/inbox' % DOMAIN) + self.assertEqual(user.inbox, '%s/inbox' % expected_id) + self.assertEqual(user.outbox, '%s/outbox' % expected_id) + self.assertIsNotNone(user.private_key) + self.assertIsNotNone(user.public_key) + + + def test_user_shelves(self): + user = models.User.objects.get(localname='mouse') + shelves = models.Shelf.objects.filter(user=user).all() + self.assertEqual(len(shelves), 3) + names = [s.name for s in shelves] + self.assertEqual(names, ['To Read', 'Currently Reading', 'Read']) + ids = [s.identifier for s in shelves] + self.assertEqual(ids, ['to-read', 'reading', 'read']) From db898e362bd55bfd193f469679186275ad8830f2 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 18:30:24 -0700 Subject: [PATCH 07/22] Tests and fixes whitespace bugs in sanitizer --- fedireads/sanitize_html.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/fedireads/sanitize_html.py b/fedireads/sanitize_html.py index 669b6888..70f63ed2 100644 --- a/fedireads/sanitize_html.py +++ b/fedireads/sanitize_html.py @@ -19,19 +19,19 @@ class InputHtmlParser(HTMLParser): self.output.append(('tag', self.get_starttag_text())) self.tag_stack.append(tag) else: - self.output.append(('data', ' ')) + self.output.append(('data', '')) def handle_endtag(self, tag): ''' keep the close tag ''' if not self.allow_html or tag not in self.whitelist: - self.output.append(('data', ' ')) + self.output.append(('data', '')) return if not self.tag_stack or self.tag_stack[-1] != tag: # the end tag doesn't match the most recent start tag self.allow_html = False - self.output.append(('data', ' ')) + self.output.append(('data', '')) return self.tag_stack = self.tag_stack[:-1] @@ -45,6 +45,8 @@ class InputHtmlParser(HTMLParser): def get_output(self): ''' convert the output from a list of tuples to a string ''' + if self.tag_stack: + self.allow_html = False if not self.allow_html: return ''.join(v for (k, v) in self.output if k == 'data') return ''.join(v for (k, v) in self.output) From 2e8afb90e60640159de847844ff5510de4b9ef38 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 18:55:45 -0700 Subject: [PATCH 08/22] Fixes bug in activitypub serialization of statuses --- fedireads/models/status.py | 21 +++++++++++++++++++++ fedireads/views.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/fedireads/models/status.py b/fedireads/models/status.py index 39cde35e..b567b9db 100644 --- a/fedireads/models/status.py +++ b/fedireads/models/status.py @@ -6,6 +6,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from model_utils.managers import InheritanceManager +from fedireads import activitypub from fedireads.utils.models import FedireadsModel @@ -48,6 +49,11 @@ class Status(FedireadsModel): return '%s/%s/%d' % (base_path, model_name, self.id) + @property + def activitypub_serialize(self): + return activitypub.get_status(self) + + class Comment(Status): ''' like a review but without a rating and transient ''' book = models.ForeignKey('Edition', on_delete=models.PROTECT) @@ -58,6 +64,11 @@ class Comment(Status): super().save(*args, **kwargs) + @property + def activitypub_serialize(self): + return activitypub.get_comment(self) + + class Quotation(Status): ''' like a review but without a rating and transient ''' book = models.ForeignKey('Edition', on_delete=models.PROTECT) @@ -69,6 +80,11 @@ class Quotation(Status): super().save(*args, **kwargs) + @property + def activitypub_serialize(self): + return activitypub.get_quotation(self) + + class Review(Status): ''' a book review ''' name = models.CharField(max_length=255, null=True) @@ -86,6 +102,11 @@ class Review(Status): super().save(*args, **kwargs) + @property + def activitypub_serialize(self): + return activitypub.get_review(self) + + class Favorite(FedireadsModel): ''' fav'ing a post ''' user = models.ForeignKey('User', on_delete=models.PROTECT) diff --git a/fedireads/views.py b/fedireads/views.py index 2d2ca9ae..a1201297 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -330,7 +330,7 @@ def status_page(request, username, status_id): return HttpResponseNotFound() if is_api_request(request): - return JsonResponse(activitypub.get_status(status)) + return JsonResponse(status.activitypub_serialize) data = { 'status': status, From ca8eb75352ec6971a8e421c8f822aed8853ceba1 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 19:22:41 -0700 Subject: [PATCH 09/22] Tests for creating reviews and statuses --- fedireads/tests/test_review.py | 81 ++++++++++++++++++++++++++++++++++ fedireads/tests/test_status.py | 64 +++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 fedireads/tests/test_review.py create mode 100644 fedireads/tests/test_status.py diff --git a/fedireads/tests/test_review.py b/fedireads/tests/test_review.py new file mode 100644 index 00000000..097a2d5e --- /dev/null +++ b/fedireads/tests/test_review.py @@ -0,0 +1,81 @@ +from django.test import TestCase + +from fedireads import models +from fedireads import status as status_builder + + +class Review(TestCase): + ''' we have hecka ways to create statuses ''' + def setUp(self): + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + self.book = models.Edition.objects.create(title='Example Edition') + + + def test_create_review(self): + review = status_builder.create_review( + self.user, self.book, 'review name', 'content', 5) + self.assertEqual(review.name, 'review name') + self.assertEqual(review.content, 'content') + self.assertEqual(review.rating, 5) + + review = status_builder.create_review( + self.user, self.book, '
review
name', 'content', 5) + self.assertEqual(review.name, 'review name') + self.assertEqual(review.content, 'content') + self.assertEqual(review.rating, 5) + + def test_review_rating(self): + review = status_builder.create_review( + self.user, self.book, 'review name', 'content', -1) + self.assertEqual(review.name, 'review name') + self.assertEqual(review.content, 'content') + self.assertEqual(review.rating, None) + + review = status_builder.create_review( + self.user, self.book, 'review name', 'content', 6) + self.assertEqual(review.name, 'review name') + self.assertEqual(review.content, 'content') + self.assertEqual(review.rating, None) + + + def test_review_from_activity(self): + activity = { + 'id': 'https://example.com/user/mouse/review/9', + 'url': 'https://example.com/user/mouse/review/9', + 'inReplyTo': None, + 'published': '2020-05-04T00:00:00.000000+00:00', + 'attributedTo': 'https://example.com/user/mouse', + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + 'https://example.com/user/mouse/followers' + ], + 'sensitive': False, + 'content': 'review content', + 'type': 'Article', + 'attachment': [], + 'replies': { + 'id': 'https://example.com/user/mouse/review/9/replies', + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'next': 'https://example.com/user/mouse/review/9/replies?only_other_accounts=true&page=true', + 'partOf': 'https://example.com/user/mouse/review/9/replies', + 'items': [] + } + }, + 'inReplyToBook': self.book.absolute_id, + 'fedireadsType': 'Review', + 'name': 'review title', + 'rating': 3 + } + review = status_builder.create_review_from_activity( + self.user, activity) + self.assertEqual(review.content, 'review content') + self.assertEqual(review.name, 'review title') + self.assertEqual(review.rating, 3) + self.assertEqual(review.book, self.book) + self.assertEqual( + review.published_date, '2020-05-04T00:00:00.000000+00:00') diff --git a/fedireads/tests/test_status.py b/fedireads/tests/test_status.py new file mode 100644 index 00000000..dbec4b26 --- /dev/null +++ b/fedireads/tests/test_status.py @@ -0,0 +1,64 @@ +from django.test import TestCase + +from fedireads import models +from fedireads import status as status_builder + + +class Status(TestCase): + ''' we have hecka ways to create statuses ''' + def setUp(self): + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + + + def test_create_status(self): + content = 'statuses are usually replies' + status = status_builder.create_status( + self.user, content) + self.assertEqual(status.content, content) + + reply = status_builder.create_status( + self.user, content, reply_parent=status) + self.assertEqual(reply.content, content) + self.assertEqual(reply.reply_parent, status) + + def test_create_status_from_activity(self): + book = models.Edition.objects.create(title='Example Edition') + review = status_builder.create_review( + self.user, book, 'review name', 'content', 5) + activity = { + 'id': 'https://example.com/user/mouse/status/12', + 'url': 'https://example.com/user/mouse/status/12', + 'inReplyTo': review.absolute_id, + 'published': '2020-05-10T02:15:59.635557+00:00', + 'attributedTo': 'https://example.com/user/mouse', + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + 'https://example.com/user/mouse/followers' + ], + 'sensitive': False, + 'content': 'reply to status', + 'type': 'Note', + 'attachment': [], + 'replies': { + 'id': 'https://example.com/user/mouse/status/12/replies', + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'next': 'https://example.com/user/mouse/status/12/replies?only_other_accounts=true&page=true', + 'partOf': 'https://example.com/user/mouse/status/12/replies', + 'items': [] + } + } + } + + status = status_builder.create_status_from_activity( + self.user, activity) + self.assertEqual(status.reply_parent, review) + self.assertEqual(status.content, 'reply to status') + self.assertEqual( + status.published_date, + '2020-05-10T02:15:59.635557+00:00' + ) From aefb718456ef43977530f51ed851bb6ca7394fe7 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 19:48:30 -0700 Subject: [PATCH 10/22] more tests --- fedireads/tests/test_quotation.py | 66 +++++++++++++++++++++++++++ fedireads/tests/test_sanitize_html.py | 50 ++++++++++++++++++++ fedireads/urls.py | 2 +- 3 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 fedireads/tests/test_quotation.py create mode 100644 fedireads/tests/test_sanitize_html.py diff --git a/fedireads/tests/test_quotation.py b/fedireads/tests/test_quotation.py new file mode 100644 index 00000000..4ddac635 --- /dev/null +++ b/fedireads/tests/test_quotation.py @@ -0,0 +1,66 @@ +from django.test import TestCase + +from fedireads import models +from fedireads import status as status_builder + + +class Quotation(TestCase): + ''' we have hecka ways to create statuses ''' + def setUp(self): + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + self.book = models.Edition.objects.create(title='Example Edition') + + + def test_create_quotation(self): + quotation = status_builder.create_quotation( + self.user, self.book, 'commentary', 'a quote') + self.assertEqual(quotation.quote, 'a quote') + self.assertEqual(quotation.content, 'commentary') + + + def test_quotation_from_activity(self): + activity = { + 'id': 'https://example.com/user/mouse/quotation/13', + 'url': 'https://example.com/user/mouse/quotation/13', + 'inReplyTo': None, + 'published': '2020-05-10T02:38:31.150343+00:00', + 'attributedTo': 'https://example.com/user/mouse', + 'to': [ + 'https://www.w3.org/ns/activitystreams#Public' + ], + 'cc': [ + 'https://example.com/user/mouse/followers' + ], + 'sensitive': False, + 'content': 'commentary', + 'type': 'Note', + 'attachment': [ + { + 'type': 'Document', + 'mediaType': 'image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg', + 'url': 'https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg', + 'name': 'Cover of \'This Is How You Lose the Time War\'' + } + ], + 'replies': { + 'id': 'https://example.com/user/mouse/quotation/13/replies', + 'type': 'Collection', + 'first': { + 'type': 'CollectionPage', + 'next': 'https://example.com/user/mouse/quotation/13/replies?only_other_accounts=true&page=true', + 'partOf': 'https://example.com/user/mouse/quotation/13/replies', + 'items': [] + } + }, + 'inReplyToBook': self.book.absolute_id, + 'fedireadsType': 'Quotation', + 'quote': 'quote body' + } + quotation = status_builder.create_quotation_from_activity( + self.user, activity) + self.assertEqual(quotation.content, 'commentary') + self.assertEqual(quotation.name, 'quote body') + self.assertEqual(quotation.book, self.book) + self.assertEqual( + quotation.published_date, '2020-05-10T02:38:31.150343+00:00') diff --git a/fedireads/tests/test_sanitize_html.py b/fedireads/tests/test_sanitize_html.py new file mode 100644 index 00000000..4bd0959f --- /dev/null +++ b/fedireads/tests/test_sanitize_html.py @@ -0,0 +1,50 @@ +from django.test import TestCase + +from fedireads.sanitize_html import InputHtmlParser + + +class Sanitizer(TestCase): + def test_no_html(self): + input_text = 'no html ' + parser = InputHtmlParser() + parser.feed(input_text) + output = parser.get_output() + self.assertEqual(input_text, output) + + + def test_valid_html(self): + input_text = 'yes html' + parser = InputHtmlParser() + parser.feed(input_text) + output = parser.get_output() + self.assertEqual(input_text, output) + + + def test_valid_html_attrs(self): + input_text = 'yes html' + parser = InputHtmlParser() + parser.feed(input_text) + output = parser.get_output() + self.assertEqual(input_text, output) + + + def test_invalid_html(self): + input_text = 'yes html' + parser = InputHtmlParser() + parser.feed(input_text) + output = parser.get_output() + self.assertEqual('yes html', output) + + input_text = 'yes html ' + parser = InputHtmlParser() + parser.feed(input_text) + output = parser.get_output() + self.assertEqual('yes html ', output) + + + def test_disallowed_html(self): + input_text = '
yes html
' + parser = InputHtmlParser() + parser.feed(input_text) + output = parser.get_output() + self.assertEqual(' yes html', output) diff --git a/fedireads/urls.py b/fedireads/urls.py index d838fc2d..1fe18507 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -10,7 +10,7 @@ username_regex = r'(?P[\w\-_]+@[\w\-\_\.]+)' localname_regex = r'(?P[\w\-_]+)' user_path = r'^user/%s' % username_regex local_user_path = r'^user/%s' % localname_regex -status_path = r'%s/(status|review|comment)/(?P\d+)' % local_user_path +status_path = r'%s/(status|review|comment|quotation)/(?P\d+)' % local_user_path book_path = r'^book/(?P\d+)' handler404 = 'fedireads.views.not_found_page' From ea420f9889a3a53e8d8dffe4b35aebf03de8ab95 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 20:15:18 -0700 Subject: [PATCH 11/22] fixes quote test --- fedireads/tests/test_quotation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fedireads/tests/test_quotation.py b/fedireads/tests/test_quotation.py index 4ddac635..45f577a8 100644 --- a/fedireads/tests/test_quotation.py +++ b/fedireads/tests/test_quotation.py @@ -60,7 +60,7 @@ class Quotation(TestCase): quotation = status_builder.create_quotation_from_activity( self.user, activity) self.assertEqual(quotation.content, 'commentary') - self.assertEqual(quotation.name, 'quote body') + self.assertEqual(quotation.quote, 'quote body') self.assertEqual(quotation.book, self.book) self.assertEqual( quotation.published_date, '2020-05-10T02:38:31.150343+00:00') From 932b4abcfedf988cbe0ad889fc5fa719fe4a0c49 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 20:15:33 -0700 Subject: [PATCH 12/22] test coment --- fedireads/tests/test_comment.py | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 fedireads/tests/test_comment.py diff --git a/fedireads/tests/test_comment.py b/fedireads/tests/test_comment.py new file mode 100644 index 00000000..2da16741 --- /dev/null +++ b/fedireads/tests/test_comment.py @@ -0,0 +1,56 @@ +from django.test import TestCase + +from fedireads import models +from fedireads import status as status_builder + + +class Comment(TestCase): + ''' we have hecka ways to create statuses ''' + def setUp(self): + self.user = models.User.objects.create_user( + 'mouse', 'mouse@mouse.mouse', 'mouseword') + self.book = models.Edition.objects.create(title='Example Edition') + + + def test_create_comment(self): + comment = status_builder.create_comment( + self.user, self.book, 'commentary') + self.assertEqual(comment.content, 'commentary') + + + def test_comment_from_activity(self): + activity = { + "id": "https://example.com/user/mouse/comment/6", + "url": "https://example.com/user/mouse/comment/6", + "inReplyTo": None, + "published": "2020-05-08T23:45:44.768012+00:00", + "attributedTo": "https://example.com/user/mouse", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.com/user/mouse/followers" + ], + "sensitive": False, + "content": "commentary", + "type": "Note", + "attachment": [], + "replies": { + "id": "https://example.com/user/mouse/comment/6/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.com/user/mouse/comment/6/replies?only_other_accounts=true&page=true", + "partOf": "https://example.com/user/mouse/comment/6/replies", + "items": [] + } + }, + "inReplyToBook": self.book.absolute_id, + "fedireadsType": "Comment" + } + comment = status_builder.create_comment_from_activity( + self.user, activity) + self.assertEqual(comment.content, 'commentary') + self.assertEqual(comment.book, self.book) + self.assertEqual( + comment.published_date, '2020-05-08T23:45:44.768012+00:00') From 45e5df388d043d8b894c1aae7f9fa39175578f93 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 21:52:13 -0700 Subject: [PATCH 13/22] Activitypub serialize shelves --- fedireads/activitypub/__init__.py | 2 +- fedireads/activitypub/book.py | 39 +++++++++++++++++++++++++++++++ fedireads/models/book.py | 9 +++++++ fedireads/models/shelf.py | 5 ++++ fedireads/urls.py | 4 ++-- fedireads/views.py | 3 ++- 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/fedireads/activitypub/__init__.py b/fedireads/activitypub/__init__.py index b75eca7e..33aefac3 100644 --- a/fedireads/activitypub/__init__.py +++ b/fedireads/activitypub/__init__.py @@ -1,6 +1,6 @@ ''' bring activitypub functions into the namespace ''' from .actor import get_actor -from .book import get_book, get_author +from .book import get_book, get_author, get_shelf from .create import get_create, get_update from .follow import get_following, get_followers from .follow import get_follow_request, get_unfollow, get_accept, get_reject diff --git a/fedireads/activitypub/book.py b/fedireads/activitypub/book.py index 04e8d193..e3c0e6bf 100644 --- a/fedireads/activitypub/book.py +++ b/fedireads/activitypub/book.py @@ -86,3 +86,42 @@ def get_author(author): if hasattr(author, field): activity[field] = author.__getattribute__(field) return activity + + +def get_shelf(shelf, page=None): + ''' serialize shelf object ''' + id_slug = shelf.absolute_id + if page: + return get_shelf_page(shelf, page) + count = shelf.books.count() + return { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': id_slug, + 'type': 'OrderedCollection', + 'totalItems': count, + 'first': '%s?page=1' % id_slug, + } + + +def get_shelf_page(shelf, page): + ''' list of books on a shelf ''' + page = int(page) + page_length = 10 + start = (page - 1) * page_length + end = start + page_length + shelf_page = shelf.books.all()[start:end] + id_slug = shelf.absolute_id + data = { + '@context': 'https://www.w3.org/ns/activitystreams', + 'id': '%s?page=%d' % (id_slug, page), + 'type': 'OrderedCollectionPage', + 'totalItems': shelf.books.count(), + 'partOf': id_slug, + 'orderedItems': [get_book(b) for b in shelf_page], + } + if end <= shelf.books.count(): + # there are still more pages + data['next'] = '%s?page=%d' % (id_slug, page + 1) + if start > 0: + data['prev'] = '%s?page=%d' % (id_slug, page - 1) + return data diff --git a/fedireads/models/book.py b/fedireads/models/book.py index a3510397..24ef3408 100644 --- a/fedireads/models/book.py +++ b/fedireads/models/book.py @@ -3,6 +3,7 @@ from django.utils import timezone from django.db import models from model_utils.managers import InheritanceManager +from fedireads import activitypub from fedireads.settings import DOMAIN from fedireads.utils.fields import JSONField, ArrayField from fedireads.utils.models import FedireadsModel @@ -110,6 +111,10 @@ class Book(FedireadsModel): self.title, ) + @property + def activitypub_serialize(self): + return activitypub.get_book(self) + class Work(Book): ''' a work (an abstract concept of a book that manifests in an edition) ''' @@ -165,3 +170,7 @@ class Author(FedireadsModel): models.CharField(max_length=255), blank=True, default=list ) bio = models.TextField(null=True, blank=True) + + @property + def activitypub_serialize(self): + return activitypub.get_author(self) diff --git a/fedireads/models/shelf.py b/fedireads/models/shelf.py index 32ea7273..993c7b7a 100644 --- a/fedireads/models/shelf.py +++ b/fedireads/models/shelf.py @@ -1,6 +1,7 @@ ''' puttin' books on shelves ''' from django.db import models +from fedireads import activitypub from fedireads.utils.models import FedireadsModel @@ -23,6 +24,10 @@ class Shelf(FedireadsModel): model_name = type(self).__name__.lower() return '%s/%s/%s' % (base_path, model_name, self.identifier) + @property + def activitypub_serialize(self): + return activitypub.get_shelf(self) + class Meta: unique_together = ('user', 'identifier') diff --git a/fedireads/urls.py b/fedireads/urls.py index 1fe18507..67109fe6 100644 --- a/fedireads/urls.py +++ b/fedireads/urls.py @@ -65,8 +65,8 @@ urlpatterns = [ re_path(r'^author/(?P[\w\-]+)(.json)?/?$', views.author_page), re_path(r'^tag/(?P.+)/?$', views.tag_page), - re_path(r'^shelf/%s/(?P[\w-]+)(.json)?/?$' % username_regex, views.shelf_page), - re_path(r'^shelf/%s/(?P[\w-]+)(.json)?/?$' % localname_regex, views.shelf_page), + re_path(r'^%s/shelf/(?P[\w-]+)(.json)?/?$' % user_path, views.shelf_page), + re_path(r'^%s/shelf/(?P[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page), re_path(r'^search/?$', views.search), diff --git a/fedireads/views.py b/fedireads/views.py index a1201297..6cb1dbe4 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -539,7 +539,8 @@ def shelf_page(request, username, shelf_identifier): shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier) if is_api_request(request): - return activitypub.get_shelf(shelf) + page = request.GET.get('page') + return JsonResponse(activitypub.get_shelf(shelf, page=page)) data = { 'shelf': shelf, From 67837f84b38623c2ec27e6fbbc28bc922a90c80b Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 21:55:00 -0700 Subject: [PATCH 14/22] user activitypub serializer --- fedireads/models/shelf.py | 4 ---- fedireads/models/user.py | 4 ++++ fedireads/views.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fedireads/models/shelf.py b/fedireads/models/shelf.py index 993c7b7a..a87a19cb 100644 --- a/fedireads/models/shelf.py +++ b/fedireads/models/shelf.py @@ -24,10 +24,6 @@ class Shelf(FedireadsModel): model_name = type(self).__name__.lower() return '%s/%s/%s' % (base_path, model_name, self.identifier) - @property - def activitypub_serialize(self): - return activitypub.get_shelf(self) - class Meta: unique_together = ('user', 'identifier') diff --git a/fedireads/models/user.py b/fedireads/models/user.py index 2f2c3dab..13fa8245 100644 --- a/fedireads/models/user.py +++ b/fedireads/models/user.py @@ -73,6 +73,10 @@ class User(AbstractUser): username = self.localname or self.username return 'https://%s/%s/%s' % (DOMAIN, model_name, username) + @property + def activitypub_serialize(self): + return activitypub.get_actor(self) + class UserRelationship(FedireadsModel): ''' many-to-many through table for followers ''' diff --git a/fedireads/views.py b/fedireads/views.py index 6cb1dbe4..09f544bb 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -239,7 +239,7 @@ def user_page(request, username, subpage=None): if is_api_request(request): # we have a json request - return JsonResponse(activitypub.get_actor(user)) + return JsonResponse(user.activitypub_serialize) # otherwise we're at a UI view # TODO: change display with privacy and authentication considerations From 54c553312f71e889e007ed35dff27a23e024c3c4 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sat, 9 May 2020 22:13:44 -0700 Subject: [PATCH 15/22] Separate import job tests out --- fedireads/tests/test_import_model.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/fedireads/tests/test_import_model.py b/fedireads/tests/test_import_model.py index bcdcafb9..6fcca0cd 100644 --- a/fedireads/tests/test_import_model.py +++ b/fedireads/tests/test_import_model.py @@ -89,23 +89,21 @@ class ImportJob(TestCase): self.assertEqual(item.date_read, expected) - def test_reads(self): - ''' various states of reading ''' - # currently reading + def test_currently_reading_reads(self): expected = [models.ReadThrough( start_date=datetime.datetime(2019, 4, 9, 0, 0))] actual = models.ImportItem.objects.get(index=1) self.assertEqual(actual.reads[0].start_date, expected[0].start_date) self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) - # read + def test_read_reads(self): expected = [models.ReadThrough( finish_date=datetime.datetime(2019, 4, 9, 0, 0))] actual = models.ImportItem.objects.get(index=2) self.assertEqual(actual.reads[0].start_date, expected[0].start_date) self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date) - # unknown dates + def test_unread_reads(self): expected = [] actual = models.ImportItem.objects.get(index=3) self.assertEqual(actual.reads, expected) From 470c5d4acb085a70548497e2381d9f76073de9e1 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 10 May 2020 09:11:01 -0700 Subject: [PATCH 16/22] Adds cursory openlibrary connector tests --- fedireads/connectors/openlibrary.py | 2 +- fedireads/tests/data/ol_edition.json | 83 +++++++++++ fedireads/tests/data/ol_search.json | 132 ++++++++++++++++++ fedireads/tests/data/ol_work.json | 63 +++++++++ fedireads/tests/test_connector_openlibrary.py | 65 +++++++++ 5 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 fedireads/tests/data/ol_edition.json create mode 100644 fedireads/tests/data/ol_search.json create mode 100644 fedireads/tests/data/ol_work.json create mode 100644 fedireads/tests/test_connector_openlibrary.py diff --git a/fedireads/connectors/openlibrary.py b/fedireads/connectors/openlibrary.py index ee73f47d..6da608ef 100644 --- a/fedireads/connectors/openlibrary.py +++ b/fedireads/connectors/openlibrary.py @@ -42,7 +42,7 @@ class Connector(AbstractConnector): return SearchResult( doc.get('title'), key, - author[0], + ', '.join(author), doc.get('first_publish_year'), ) diff --git a/fedireads/tests/data/ol_edition.json b/fedireads/tests/data/ol_edition.json new file mode 100644 index 00000000..459e9dff --- /dev/null +++ b/fedireads/tests/data/ol_edition.json @@ -0,0 +1,83 @@ +{ + "identifiers": { + "librarything": [ + "10014" + ], + "goodreads": [ + "535197", + "1102517", + "518848" + ] + }, + "lc_classifications": [ + "PZ7.N647 Sab 1995" + ], + "latest_revision": 7, + "ocaid": "sabriel00nixg", + "ia_box_id": [ + "IA107202" + ], + "edition_name": "1st American ed.", + "title": "Sabriel", + "languages": [ + { + "key": "/languages/eng" + } + ], + "subjects": [ + "Fantasy." + ], + "publish_country": "nyu", + "by_statement": "Garth Nix.", + "type": { + "key": "/type/edition" + }, + "revision": 7, + "publishers": [ + "Harper Trophy" + ], + "description": { + "type": "/type/text", + "value": "Sabriel, daughter of the necromancer Abhorsen, must journey into the mysterious and magical Old Kingdom to rescue her father from the Land of the Dead." + }, + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "key": "/books/OL22951843M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "publish_places": [ + "New York" + ], + "pagination": "491 p. :", + "created": { + "type": "/type/datetime", + "value": "2009-02-12T16:29:58.929717" + }, + "dewey_decimal_class": [ + "[Fic]" + ], + "notes": { + "type": "/type/text", + "value": "Originally published: Australia : HarperCollins, 1995." + }, + "number_of_pages": 491, + "lccn": [ + "96001295" + ], + "isbn_10": [ + "0060273224", + "0060273232", + "0064471837" + ], + "publish_date": "1996", + "works": [ + { + "key": "/works/OL15832982W" + } + ] +} diff --git a/fedireads/tests/data/ol_search.json b/fedireads/tests/data/ol_search.json new file mode 100644 index 00000000..4ac51bac --- /dev/null +++ b/fedireads/tests/data/ol_search.json @@ -0,0 +1,132 @@ +{ + "start": 0, + "num_found": 2, + "numFound": 2, + "docs": [ + { + "title_suggest": "This Is How You Lose the Time War", + "edition_key": [ + "OL27901088M" + ], + "isbn": [ + "9781534431003", + "1534431004" + ], + "has_fulltext": false, + "text": [ + "OL27901088M", + "9781534431003", + "1534431004", + "Amal El-Mohtar", + "Max Gladstone", + "OL7313207A", + "OL7129451A", + "epistolary", + "science fiction", + "time-traveling", + "LGBT", + "This Is How You Lose the Time War", + "/works/OL20639540W", + "Simon and Schuster", + "Atlantis", + "London", + "The whole of time and space" + ], + "author_name": [ + "Amal El-Mohtar", + "Max Gladstone" + ], + "seed": [ + "/books/OL27901088M", + "/works/OL20639540W", + "/subjects/science_fiction", + "/subjects/time-traveling", + "/subjects/epistolary", + "/subjects/lgbt", + "/subjects/place:london", + "/subjects/place:atlantis", + "/subjects/time:the_whole_of_time_and_space", + "/authors/OL7313207A", + "/authors/OL7129451A" + ], + "author_key": [ + "OL7313207A", + "OL7129451A" + ], + "availability": { + "status": "error" + }, + "subject": [ + "epistolary", + "science fiction", + "time-traveling", + "LGBT" + ], + "title": "This Is How You Lose the Time War", + "publish_date": [ + "July 16, 2019" + ], + "type": "work", + "ebook_count_i": 0, + "publish_place": [ + "New York, USA" + ], + "edition_count": 1, + "key": "/works/OL20639540W", + "publisher": [ + "Simon and Schuster" + ], + "language": [ + "eng" + ], + "last_modified_i": 1579909341, + "cover_edition_key": "OL27901088M", + "publish_year": [ + 2019 + ], + "first_publish_year": 2019, + "place": [ + "Atlantis", + "London" + ], + "time": [ + "The whole of time and space" + ] + }, + { + "title_suggest": "This is How You Lose the Time War", + "cover_i": 8665647, + "has_fulltext": false, + "title": "This is How You Lose the Time War", + "last_modified_i": 1561998020, + "edition_count": 0, + "author_name": [ + "Amal El-Mohtar", + "Max Gladstone" + ], + "seed": [ + "/works/OL19859295W", + "/authors/OL7313207A", + "/authors/OL7129451A" + ], + "key": "/works/OL19859295W", + "text": [ + "Amal El-Mohtar", + "Max Gladstone", + "OL7313207A", + "OL7129451A", + "This is How You Lose the Time War", + "/works/OL19859295W" + ], + "author_key": [ + "OL7313207A", + "OL7129451A" + ], + "type": "work", + "availability": { + "status": "error" + }, + "ebook_count_i": 0 + } + ] +} diff --git a/fedireads/tests/data/ol_work.json b/fedireads/tests/data/ol_work.json new file mode 100644 index 00000000..8587845b --- /dev/null +++ b/fedireads/tests/data/ol_work.json @@ -0,0 +1,63 @@ +{ + "first_publish_date": "1995", + "key": "/works/OL15832982W", + "description": { + "type": "/type/text", + "value": "First in the Old Kingdom/Abhorsen series." + }, + "created": { + "type": "/type/datetime", + "value": "2011-07-07T16:30:28.384311" + }, + "title": "Sabriel", + "covers": [ + 6796986, + 3843137 + ], + "first_sentence": { + "type": "/type/text", + "value": "THE RABBIT HAD been run over minutes before." + }, + "excerpts": [ + { + "excerpt": "THE RABBIT HAD been run over minutes before." + } + ], + "lc_classifications": [ + "PZ7.N647 Sab 1995" + ], + "latest_revision": 5, + "last_modified": { + "type": "/type/datetime", + "value": "2019-07-22T13:57:34.579651" + }, + "authors": [ + { + "type": { + "key": "/type/author_role" + }, + "author": { + "key": "/authors/OL382982A" + } + } + ], + "dewey_number": [ + "[Fic]" + ], + "subjects": [ + "Fantasy", + "Science Fiction & Fantasy", + "Fantasy fiction", + "Fiction", + "Juvenile Fiction", + "Juvenile fiction", + "Magical thinking" + ], + "type": { + "key": "/type/work" + }, + "subject_times": [ + "Life and Death." + ], + "revision": 5 +} diff --git a/fedireads/tests/test_connector_openlibrary.py b/fedireads/tests/test_connector_openlibrary.py new file mode 100644 index 00000000..eb2e6b23 --- /dev/null +++ b/fedireads/tests/test_connector_openlibrary.py @@ -0,0 +1,65 @@ +''' testing book data connectors ''' +from dateutil import parser +from django.test import TestCase +import json +import pathlib +import pytz + +from fedireads import models +from fedireads.connectors.openlibrary import Connector +from fedireads.connectors.openlibrary import get_languages, get_description +from fedireads.connectors.abstract_connector import SearchResult, get_date + + +class Openlibrary(TestCase): + def setUp(self): + models.Connector.objects.create( + identifier='openlibrary.org', + name='OpenLibrary', + connector_file='openlibrary', + base_url='https://openlibrary.org', + books_url='https://openlibrary.org', + covers_url='https://covers.openlibrary.org', + search_url='https://openlibrary.org/search?q=', + key_name='openlibrary_key', + ) + self.connector = Connector('openlibrary.org') + + work_file = pathlib.Path(__file__).parent.joinpath( + 'data/ol_work.json') + edition_file = pathlib.Path(__file__).parent.joinpath( + 'data/ol_edition.json') + self.work_data = json.loads(work_file.read_bytes()) + self.edition_data = json.loads(edition_file.read_bytes()) + + + def test_format_search_result(self): + ''' translate json from openlibrary into SearchResult ''' + datafile = pathlib.Path(__file__).parent.joinpath('data/ol_search.json') + search_data = json.loads(datafile.read_bytes()) + results = self.connector.parse_search_data(search_data) + self.assertIsInstance(results, list) + + result = self.connector.format_search_result(results[0]) + self.assertIsInstance(result, SearchResult) + self.assertEqual(result.title, 'This Is How You Lose the Time War') + self.assertEqual(result.key, 'OL20639540W') + self.assertEqual(result.author, 'Amal El-Mohtar, Max Gladstone') + self.assertEqual(result.year, 2019) + + + def test_get_description(self): + description = get_description(self.work_data['description']) + expected = 'First in the Old Kingdom/Abhorsen series.' + self.assertEqual(description, expected) + + + def test_get_date(self): + date = get_date(self.work_data['first_publish_date']) + expected = pytz.utc.localize(parser.parse('1995')) + self.assertEqual(date, expected) + + + def test_get_languages(self): + languages = get_languages(self.edition_data['languages']) + self.assertEqual(languages, ['English']) From 72b4e7da76c1f44e970ec0ff60d9c6eb42499277 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 10 May 2020 11:29:10 -0700 Subject: [PATCH 17/22] Small code cleanup --- fedireads/connectors/abstract_connector.py | 25 ++++++++++++--------- fedireads/connectors/fedireads_connector.py | 2 +- fedireads/incoming.py | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/fedireads/connectors/abstract_connector.py b/fedireads/connectors/abstract_connector.py index 83729bb3..79707ba5 100644 --- a/fedireads/connectors/abstract_connector.py +++ b/fedireads/connectors/abstract_connector.py @@ -17,16 +17,19 @@ class AbstractConnector(ABC): self.book_mappings = {} - self.base_url = info.base_url - self.books_url = info.books_url - self.covers_url = info.covers_url - self.search_url = info.search_url - self.key_name = info.key_name - self.max_query_count = info.max_query_count - self.name = info.name - self.local = info.local - self.id = info.id - self.identifier = info.identifier + fields = [ + 'base_url', + 'books_url', + 'covers_url', + 'search_url', + 'key_name', + 'max_query_count', + 'name', + 'identifier', + 'local' + ] + for field in fields: + setattr(self, field, getattr(info, field)) def is_available(self): @@ -235,7 +238,7 @@ def get_data(url): return data -class SearchResult: +class SearchResult(object): ''' standardized search result object ''' def __init__(self, title, key, author, year): self.title = title diff --git a/fedireads/connectors/fedireads_connector.py b/fedireads/connectors/fedireads_connector.py index 83705e66..65e34282 100644 --- a/fedireads/connectors/fedireads_connector.py +++ b/fedireads/connectors/fedireads_connector.py @@ -131,7 +131,7 @@ class Connector(AbstractConnector): def get_cover(cover_url): - ''' ask openlibrary for the cover ''' + ''' download the cover ''' image_name = cover_url.split('/')[-1] response = requests.get(cover_url) if not response.ok: diff --git a/fedireads/incoming.py b/fedireads/incoming.py index 206da9d4..580d978f 100644 --- a/fedireads/incoming.py +++ b/fedireads/incoming.py @@ -310,7 +310,7 @@ def handle_boost(activity): @app.task def handle_tag(activity): - ''' someone is tagging or shelving a book ''' + ''' someone is tagging a book ''' user = get_or_create_remote_user(activity['actor']) if not user.local: book = activity['target']['id'] From 2e7d2f96f8cf6912cf65fbe38f3156a15e2c5811 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 10 May 2020 13:38:47 -0700 Subject: [PATCH 18/22] Cleans up base model --- fedireads/{utils/models.py => models/base_model.py} | 4 +++- fedireads/models/book.py | 2 +- fedireads/models/shelf.py | 2 +- fedireads/models/status.py | 9 +-------- fedireads/models/user.py | 2 +- 5 files changed, 7 insertions(+), 12 deletions(-) rename fedireads/{utils/models.py => models/base_model.py} (91%) diff --git a/fedireads/utils/models.py b/fedireads/models/base_model.py similarity index 91% rename from fedireads/utils/models.py rename to fedireads/models/base_model.py index bedc8e8e..cd1cae07 100644 --- a/fedireads/utils/models.py +++ b/fedireads/models/base_model.py @@ -3,7 +3,6 @@ from django.db import models from fedireads.settings import DOMAIN -# TODO maybe this should be in /models? class FedireadsModel(models.Model): ''' fields and functions for every model ''' created_date = models.DateTimeField(auto_now_add=True) @@ -12,6 +11,9 @@ class FedireadsModel(models.Model): @property def absolute_id(self): ''' constructs the absolute reference to any db object ''' + if self.remote_id: + return self.remote_id + base_path = 'https://%s' % DOMAIN if hasattr(self, 'user'): base_path = self.user.absolute_id diff --git a/fedireads/models/book.py b/fedireads/models/book.py index 24ef3408..32573414 100644 --- a/fedireads/models/book.py +++ b/fedireads/models/book.py @@ -6,7 +6,7 @@ from model_utils.managers import InheritanceManager from fedireads import activitypub from fedireads.settings import DOMAIN from fedireads.utils.fields import JSONField, ArrayField -from fedireads.utils.models import FedireadsModel +from .base_model import FedireadsModel from fedireads.connectors.settings import CONNECTORS diff --git a/fedireads/models/shelf.py b/fedireads/models/shelf.py index a87a19cb..ff625a09 100644 --- a/fedireads/models/shelf.py +++ b/fedireads/models/shelf.py @@ -2,7 +2,7 @@ from django.db import models from fedireads import activitypub -from fedireads.utils.models import FedireadsModel +from .base_model import FedireadsModel class Shelf(FedireadsModel): diff --git a/fedireads/models/status.py b/fedireads/models/status.py index b567b9db..171d1a67 100644 --- a/fedireads/models/status.py +++ b/fedireads/models/status.py @@ -7,7 +7,7 @@ from django.db import models from model_utils.managers import InheritanceManager from fedireads import activitypub -from fedireads.utils.models import FedireadsModel +from .base_model import FedireadsModel class Status(FedireadsModel): @@ -113,13 +113,6 @@ class Favorite(FedireadsModel): status = models.ForeignKey('Status', on_delete=models.PROTECT) remote_id = models.CharField(max_length=255, unique=True, null=True) - @property - def absolute_id(self): - ''' constructs the absolute reference to any db object ''' - if self.remote_id: - return self.remote_id - return super().absolute_id - class Meta: unique_together = ('user', 'status') diff --git a/fedireads/models/user.py b/fedireads/models/user.py index 13fa8245..477617e3 100644 --- a/fedireads/models/user.py +++ b/fedireads/models/user.py @@ -7,7 +7,7 @@ from django.dispatch import receiver from fedireads.models.shelf import Shelf from fedireads.settings import DOMAIN -from fedireads.utils.models import FedireadsModel +from .base_model import FedireadsModel class User(AbstractUser): From 5e78de1340a4fdf5f2e84ecf1f0f95d50c10c30c Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 10 May 2020 13:42:35 -0700 Subject: [PATCH 19/22] the way search keys work has changed, updating test --- fedireads/tests/test_connector_openlibrary.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fedireads/tests/test_connector_openlibrary.py b/fedireads/tests/test_connector_openlibrary.py index eb2e6b23..38e2b818 100644 --- a/fedireads/tests/test_connector_openlibrary.py +++ b/fedireads/tests/test_connector_openlibrary.py @@ -43,7 +43,7 @@ class Openlibrary(TestCase): result = self.connector.format_search_result(results[0]) self.assertIsInstance(result, SearchResult) self.assertEqual(result.title, 'This Is How You Lose the Time War') - self.assertEqual(result.key, 'OL20639540W') + self.assertEqual(result.key, 'https://openlibrary.org/works/OL20639540W') self.assertEqual(result.author, 'Amal El-Mohtar, Max Gladstone') self.assertEqual(result.year, 2019) From d5f46a1c6f78fde4dfd5e60879fe5eaa1137ec50 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 10 May 2020 14:12:03 -0700 Subject: [PATCH 20/22] more openlibrary connector tests --- fedireads/connectors/openlibrary.py | 2 +- fedireads/tests/test_connector_openlibrary.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/fedireads/connectors/openlibrary.py b/fedireads/connectors/openlibrary.py index 4dc1353e..e4ee74b4 100644 --- a/fedireads/connectors/openlibrary.py +++ b/fedireads/connectors/openlibrary.py @@ -35,7 +35,7 @@ class Connector(AbstractConnector): def is_work_data(self, data): - return not re.match(r'^OL\d+M$', data['key']) + return bool(re.match(r'^[\/\w]+OL\d+W$', data['key'])) def get_edition_from_work_data(self, data): diff --git a/fedireads/tests/test_connector_openlibrary.py b/fedireads/tests/test_connector_openlibrary.py index 38e2b818..68bace67 100644 --- a/fedireads/tests/test_connector_openlibrary.py +++ b/fedireads/tests/test_connector_openlibrary.py @@ -8,6 +8,7 @@ import pytz from fedireads import models from fedireads.connectors.openlibrary import Connector from fedireads.connectors.openlibrary import get_languages, get_description +from fedireads.connectors.openlibrary import pick_default_edition from fedireads.connectors.abstract_connector import SearchResult, get_date @@ -29,8 +30,21 @@ class Openlibrary(TestCase): 'data/ol_work.json') edition_file = pathlib.Path(__file__).parent.joinpath( 'data/ol_edition.json') + edition_list_file = pathlib.Path(__file__).parent.joinpath( + 'data/ol_edition_list.json') self.work_data = json.loads(work_file.read_bytes()) self.edition_data = json.loads(edition_file.read_bytes()) + self.edition_list_data = json.loads(edition_list_file.read_bytes()) + + + def test_is_work_data(self): + self.assertEqual(self.connector.is_work_data(self.work_data), True) + self.assertEqual(self.connector.is_work_data(self.edition_data), False) + + + def test_pick_default_edition(self): + edition = pick_default_edition(self.edition_list_data['entries']) + self.assertEqual(edition['key'], '/books/OL9952943M') def test_format_search_result(self): From 7493abeb53a8a520ec99d8cbc263c726945e6f3f Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 10 May 2020 14:53:45 -0700 Subject: [PATCH 21/22] Tests for fedireads connector --- fedireads/connectors/abstract_connector.py | 6 + fedireads/tests/data/fr_edition.json | 42 + fedireads/tests/data/fr_search.json | 1 + fedireads/tests/data/fr_work.json | 44 + fedireads/tests/data/ol_edition_list.json | 1018 +++++++++++++++++++ fedireads/tests/test_connector_fedireads.py | 66 ++ fedireads/view_actions.py | 1 + fedireads/views.py | 3 +- 8 files changed, 1179 insertions(+), 2 deletions(-) create mode 100644 fedireads/tests/data/fr_edition.json create mode 100644 fedireads/tests/data/fr_search.json create mode 100644 fedireads/tests/data/fr_work.json create mode 100644 fedireads/tests/data/ol_edition_list.json create mode 100644 fedireads/tests/test_connector_fedireads.py diff --git a/fedireads/connectors/abstract_connector.py b/fedireads/connectors/abstract_connector.py index 24573f1d..1e5c2665 100644 --- a/fedireads/connectors/abstract_connector.py +++ b/fedireads/connectors/abstract_connector.py @@ -284,8 +284,14 @@ def get_date(date_string): ''' helper function to try to interpret dates ''' if not date_string: return None + try: return pytz.utc.localize(parser.parse(date_string)) + except ValueError: + pass + + try: + return parser.parse(date_string) except ValueError: return None diff --git a/fedireads/tests/data/fr_edition.json b/fedireads/tests/data/fr_edition.json new file mode 100644 index 00000000..bdb7c150 --- /dev/null +++ b/fedireads/tests/data/fr_edition.json @@ -0,0 +1,42 @@ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Document", + "book_type": "Edition", + "name": "Jonathan Strange and Mr Norrell", + "url": "https://example.com/book/122", + "authors": [ + "https://example.com/author/25" + ], + "published_date": "2017-05-10T00:00:00+00:00", + "work": { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Document", + "book_type": "Work", + "name": "Jonathan Strange and Mr Norrell", + "url": "https://example.com/book/121", + "authors": [ + "https://example.com/author/25" + ], + "title": "Jonathan Strange and Mr Norrell", + "attachment": [ + { + "type": "Document", + "mediaType": "image/jpg", + "url": "https://example.com/images/covers/8775540-M.jpg", + "name": "Cover of \"Jonathan Strange and Mr Norrell\"" + } + ] + }, + "title": "Jonathan Strange and Mr Norrell", + "subtitle": "Bloomsbury Modern Classics", + "isbn_13": "9781408891469", + "physical_format": "paperback", + "attachment": [ + { + "type": "Document", + "mediaType": "image/jpg", + "url": "https://example.com/images/covers/9155821-M.jpg", + "name": "Cover of \"Jonathan Strange and Mr Norrell\"" + } + ] +} diff --git a/fedireads/tests/data/fr_search.json b/fedireads/tests/data/fr_search.json new file mode 100644 index 00000000..05dcf4f8 --- /dev/null +++ b/fedireads/tests/data/fr_search.json @@ -0,0 +1 @@ +[{"title": "Jonathan Strange and Mr Norrell", "key": "https://example.com/book/122", "author": "Susanna Clarke", "year": 2017}] diff --git a/fedireads/tests/data/fr_work.json b/fedireads/tests/data/fr_work.json new file mode 100644 index 00000000..e93f6706 --- /dev/null +++ b/fedireads/tests/data/fr_work.json @@ -0,0 +1,44 @@ +{ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Document", + "book_type": "Work", + "name": "Jonathan Strange and Mr Norrell", + "url": "https://example.com/book/121", + "authors": [ + "https://example.com/author/25" + ], + "editions": [ + { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Document", + "book_type": "Edition", + "name": "Jonathan Strange and Mr Norrell", + "url": "https://example.com/book/122", + "authors": [ + "https://example.com/author/25" + ], + "published_date": "2017-05-10T00:00:00+00:00", + "title": "Jonathan Strange and Mr Norrell", + "subtitle": "Bloomsbury Modern Classics", + "isbn_13": "9781408891469", + "physical_format": "paperback", + "attachment": [ + { + "type": "Document", + "mediaType": "image/jpg", + "url": "https://example.com/images/covers/9155821-M.jpg", + "name": "Cover of \"Jonathan Strange and Mr Norrell\"" + } + ] + } + ], + "title": "Jonathan Strange and Mr Norrell", + "attachment": [ + { + "type": "Document", + "mediaType": "image/jpg", + "url": "https://example.com/images/covers/8775540-M.jpg", + "name": "Cover of \"Jonathan Strange and Mr Norrell\"" + } + ] +} diff --git a/fedireads/tests/data/ol_edition_list.json b/fedireads/tests/data/ol_edition_list.json new file mode 100644 index 00000000..7954ad3b --- /dev/null +++ b/fedireads/tests/data/ol_edition_list.json @@ -0,0 +1,1018 @@ +{ + "entries": [ + { + "publishers": [ + "Rba Libros" + ], + "physical_format": "Hardcover", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 6, + "key": "/books/OL9142127M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "subjects": [ + "Action & Adventure - General", + "Science Fiction, Fantasy, & Magic", + "Children's 12-Up - Fiction - Fantasy" + ], + "languages": [ + { + "key": "/languages/spa" + } + ], + "title": "Sabriel", + "identifiers": { + "librarything": [ + "10014" + ], + "goodreads": [ + "461102" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2008-04-30T09:38:13.731961" + }, + "isbn_13": [ + "9788478710539" + ], + "isbn_10": [ + "8478710531" + ], + "publish_date": "November 2006", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "first_sentence": { + "type": "/type/text", + "value": "THE RABBIT HAD been run over minutes before." + }, + "revision": 6 + }, + { + "publishers": [ + "Harper Trophy" + ], + "number_of_pages": 491, + "ia_box_id": [ + "IA107202" + ], + "isbn_10": [ + "0060273224", + "0060273232", + "0064471837" + ], + "lc_classifications": [ + "PZ7.N647 Sab 1995" + ], + "latest_revision": 7, + "key": "/books/OL22951843M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "ocaid": "sabriel00nixg", + "publish_places": [ + "New York" + ], + "description": { + "type": "/type/text", + "value": "Sabriel, daughter of the necromancer Abhorsen, must journey into the mysterious and magical Old Kingdom to rescue her father from the Land of the Dead." + }, + "languages": [ + { + "key": "/languages/eng" + } + ], + "pagination": "491 p. :", + "title": "Sabriel", + "dewey_decimal_class": [ + "[Fic]" + ], + "notes": { + "type": "/type/text", + "value": "Originally published: Australia : HarperCollins, 1995." + }, + "identifiers": { + "goodreads": [ + "535197", + "1102517", + "518848" + ], + "librarything": [ + "10014" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2009-02-12T16:29:58.929717" + }, + "edition_name": "1st American ed.", + "lccn": [ + "96001295" + ], + "subjects": [ + "Fantasy." + ], + "publish_date": "1996", + "publish_country": "nyu", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "by_statement": "Garth Nix.", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 7 + }, + { + "publishers": [ + "Listening Library" + ], + "physical_format": "Audio CD", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 6, + "key": "/books/OL7946150M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "contributions": [ + "Tim Curry (Narrator)" + ], + "subjects": [ + "Classics", + "Children's Audio - 9-12" + ], + "isbn_13": [ + "9780807216057" + ], + "title": "Sabriel", + "identifiers": { + "goodreads": [ + "1226951" + ], + "librarything": [ + "10014" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2008-04-29T15:03:11.581851" + }, + "languages": [ + { + "key": "/languages/eng" + } + ], + "isbn_10": [ + "0807216054" + ], + "publish_date": "January 2006", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 6 + }, + { + "publishers": [ + "Eos" + ], + "number_of_pages": 336, + "physical_format": "Paperback", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 7, + "key": "/books/OL9952943M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "subjects": [ + "Action & Adventure - General", + "Science Fiction, Fantasy, & Magic", + "Juvenile Fiction / Science Fiction, Fantasy, Magic", + "General", + "Juvenile Fiction", + "Children's Books - Young Adult Fiction", + "Children: Young Adult (Gr. 7-9)" + ], + "first_sentence": { + "type": "/type/text", + "value": "THE RABBIT HAD been run over minutes before." + }, + "created": { + "type": "/type/datetime", + "value": "2008-04-30T09:38:13.731961" + }, + "identifiers": { + "goodreads": [ + "2254009" + ], + "librarything": [ + "10014" + ] + }, + "isbn_13": [ + "9780061474354" + ], + "languages": [ + { + "key": "/languages/eng" + } + ], + "isbn_10": [ + "0061474355" + ], + "publish_date": "April 22, 2008", + "title": "Sabriel", + "oclc_numbers": [ + "227382439" + ], + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "physical_dimensions": "8 x 5.3 x 0.8 inches", + "revision": 7 + }, + { + "publishers": [ + "Tandem Library" + ], + "identifiers": { + "goodreads": [ + "1149573" + ], + "librarything": [ + "10014" + ] + }, + "weight": "9.8 ounces", + "isbn_10": [ + "0613035976" + ], + "covers": [ + 5014078 + ], + "physical_format": "School & Library Binding", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 8, + "key": "/books/OL9788823M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "languages": [ + { + "key": "/languages/eng" + } + ], + "title": "Sabriel", + "number_of_pages": 491, + "isbn_13": [ + "9780613035972" + ], + "created": { + "type": "/type/datetime", + "value": "2008-04-30T09:38:13.731961" + }, + "first_sentence": { + "type": "/type/text", + "value": "THE RABBIT HAD been run over minutes before." + }, + "subjects": [ + "Action & Adventure - General", + "Science Fiction, Fantasy, & Magic", + "Children's 12-Up - Fiction - Fantasy", + "Fantastic fiction", + "Children: Young Adult (Gr. 7-9)" + ], + "publish_date": "October 1999", + "oclc_numbers": [ + "228061047" + ], + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "physical_dimensions": "6.7 x 3.9 x 1.2 inches", + "revision": 8 + }, + { + "publishers": [ + "Collins" + ], + "number_of_pages": 368, + "covers": [ + 10070 + ], + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 7, + "key": "/books/OL7262051M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "first_sentence": { + "type": "/type/text", + "value": "THE RABBIT HAD been run over minutes before." + }, + "title": "Sabriel", + "identifiers": { + "goodreads": [ + "1102512" + ], + "librarything": [ + "10014" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2008-04-29T13:35:46.876380" + }, + "isbn_13": [ + "9780007137305" + ], + "isbn_10": [ + "0007137303" + ], + "publish_date": "2002", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 7 + }, + { + "publishers": [ + "Perfection Learning Prebound" + ], + "physical_format": "Unknown Binding", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 6, + "key": "/books/OL9486423M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "subjects": [ + "Fiction" + ], + "first_sentence": { + "type": "/type/text", + "value": "THE RABBIT HAD been run over minutes before." + }, + "created": { + "type": "/type/datetime", + "value": "2008-04-30T09:38:13.731961" + }, + "identifiers": { + "goodreads": [ + "3176822" + ], + "librarything": [ + "10014" + ] + }, + "isbn_13": [ + "9780780772311" + ], + "isbn_10": [ + "0780772318" + ], + "publish_date": "September 1997", + "title": "Sabriel", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 6 + }, + { + "publishers": [ + "Collins" + ], + "number_of_pages": 368, + "covers": [ + 10069 + ], + "physical_format": "Hardcover", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 7, + "key": "/books/OL9216766M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "first_sentence": { + "type": "/type/text", + "value": "THE RABBIT HAD been run over minutes before." + }, + "title": "Sabriel", + "identifiers": { + "goodreads": [ + "1102512" + ], + "librarything": [ + "10014" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2008-04-30T09:38:13.731961" + }, + "isbn_13": [ + "9780007137305" + ], + "isbn_10": [ + "0007137303" + ], + "publish_date": "2002", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 7 + }, + { + "publishers": [ + "HarperCollins" + ], + "covers": [ + 6419775 + ], + "physical_format": "Electronic resource", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 4, + "key": "/books/OL24278530M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "publish_places": [ + "New York" + ], + "languages": [ + { + "key": "/languages/eng" + } + ], + "source_records": [ + "marc_overdrive/InternetArchiveCrMarc-2010-06-11p.mrc:7615355:1976" + ], + "title": "Sabriel", + "identifiers": { + "overdrive": [ + "F940DC17-8420-498F-994F-D63CE5DBC902" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2010-06-18T11:48:53.716798" + }, + "isbn_13": [ + "9780060005467", + "9780060773977" + ], + "publish_date": "2001", + "publish_country": "nyu", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 4 + }, + { + "publishers": [ + "Collins Audio" + ], + "number_of_pages": 640, + "weight": "12.6 ounces", + "isbn_10": [ + "0007146949" + ], + "covers": [ + 2309591 + ], + "physical_format": "Audio Cassette", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 8, + "key": "/books/OL9922813M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "contributions": [ + "Tim Curry (Narrator)" + ], + "title": "Sabriel", + "identifiers": { + "librarything": [ + "10014" + ], + "goodreads": [ + "1102511" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2008-04-30T09:38:13.731961" + }, + "isbn_13": [ + "9780007146949" + ], + "subjects": [ + "Fantasy", + "Children: Young Adult (Gr. 7-9)" + ], + "publish_date": "September 2, 2002", + "oclc_numbers": [ + "155997306" + ], + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "physical_dimensions": "5.4 x 4.2 x 2 inches", + "revision": 8 + }, + { + "publishers": [ + "Listening Library" + ], + "physical_format": "Audio Cassette", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 3, + "key": "/books/OL9370923M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "subjects": [ + "Unabridged Audio - Fiction/General", + "Juvenile Fiction", + "Children's audiobooks", + "Abridged Audio - Fiction/Religious", + "Audio: Juvenile", + "Science Fiction, Fantasy, & Magic", + "General", + "Audiobooks", + "Fantasy fiction" + ], + "edition_name": "Unabridged library edition", + "languages": [ + { + "key": "/languages/eng" + } + ], + "created": { + "type": "/type/datetime", + "value": "2008-04-30T09:38:13.731961" + }, + "title": "Sabriel", + "isbn_13": [ + "9780807205631" + ], + "isbn_10": [ + "080720563X" + ], + "publish_date": "April 23, 2002", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 3 + }, + { + "publishers": [ + "Listening Library" + ], + "identifiers": { + "goodreads": [ + "1149574" + ], + "librarything": [ + "10014" + ] + }, + "weight": "10.1 ounces", + "isbn_10": [ + "0807205567" + ], + "covers": [ + 589207 + ], + "physical_format": "Audio Cassette", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 7, + "key": "/books/OL7946044M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "contributions": [ + "Tim Curry (Narrator)" + ], + "languages": [ + { + "key": "/languages/eng" + } + ], + "title": "Sabriel", + "created": { + "type": "/type/datetime", + "value": "2008-04-29T15:03:11.581851" + }, + "isbn_13": [ + "9780807205563" + ], + "edition_name": "Unabridged edition", + "subjects": [ + "Juvenile Fiction", + "Classics", + "Children's Audio - 4-8", + "Children's Audio - 9-12", + "Audio: Juvenile", + "Science Fiction, Fantasy, & Magic", + "Fantasy fiction", + "Juvenile Fiction / General", + "Audiobooks", + "Children's audiobooks" + ], + "publish_date": "April 23, 2002", + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "physical_dimensions": "6.6 x 4 x 2.7 inches", + "revision": 7 + }, + { + "publishers": [ + "Allen & Unwin Pty Ltd" + ], + "description": { + "type": "/type/text", + "value": "The first book in the best-selling Old Kingdom trilogy. This compelling high fantasy is a passionately exciting tale of a young heroine who must do battle in the realm of Death itself to defeat a powerful enemy.For many years Sabriel has lived outside the walls of the Old Kingdom, away from the random power of Free Magic, and away from the Dead who won't stay dead. But now her father, the Mage Abhorsen, is missing, and to find him Sabriel must cross back into that treacherous world - and face the power of her own extraordinary destiny.This beautiful edition includes part of the original hand-written manuscript of Sabriel, an explanation by Garth Nix of his writing process, and a guide to the Necromancers' Bells.'Sabriel is a winner, a fantasy that reads like realism. Here is a world with the same solidity and four-dimensional authority as our own, created with invention, clarity and intelligence.' PHILIP PULLMAN'Passionately exciting, full of intriguing characters and stunning scenery, Sabriel is sheer enjoyment.' THE TIMES'Weaving horror and fantasy into a rich, original story ... a powerful, gripping quest.' THE AGEWinner of the Aurealis Award for Excellence in Australian Speculative Fiction" + }, + "physical_format": "eBook", + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "latest_revision": 3, + "key": "/books/OL24285280M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "publish_places": [ + "Sydney" + ], + "isbn_13": [ + "9781741769586" + ], + "source_records": [ + "marc_overdrive/InternetArchiveCrMarc-2010-06-11e.mrc:2003008:2456" + ], + "title": "Sabriel", + "identifiers": { + "overdrive": [ + "10864286-6F96-45CA-8368-38F67DE901A8" + ] + }, + "created": { + "type": "/type/datetime", + "value": "2010-06-22T20:56:33.526569" + }, + "languages": [ + { + "key": "/languages/eng" + } + ], + "publish_date": "2010", + "publish_country": "nyu", + "oclc_numbers": [ + "606614533" + ], + "works": [ + { + "key": "/works/OL15832982W" + } + ], + "type": { + "key": "/type/edition" + }, + "revision": 3 + }, + { + "number_of_pages": 292, + "covers": [ + 3843137, + 24365 + ], + "lc_classifications": [ + "PZ7.N647 Sab 1995" + ], + "latest_revision": 14, + "ocaid": "sabrielnixg00nixg", + "description": { + "type": "/type/text", + "value": "Sabriel, daughter of the necromancer Abhorsen, must journey into the mysterious and magical Old Kingdom to rescue her father from the Land of the Dead." + }, + "uri_descriptions": [ + "Publisher description" + ], + "edition_name": "1st American ed.", + "source_records": [ + "marc:marc_records_scriblio_net/part25.dat:166260532:904", + "marc:marc_loc_updates/v37.i22.records.utf8:7860393:1020", + "ia:sabrielnixg00nixg" + ], + "title": "Sabriel", + "languages": [ + { + "key": "/languages/eng" + } + ], + "subjects": [ + "Fantasy" + ], + "publish_country": "nyu", + "by_statement": "Garth Nix.", + "type": { + "key": "/type/edition" + }, + "uris": [ + "http://www.loc.gov/catdir/description/hc041/96001295.html" + ], + "revision": 14, + "publishers": [ + "HarperCollins" + ], + "ia_box_id": [ + "IA121618" + ], + "last_modified": { + "type": "/type/datetime", + "value": "2017-10-08T21:20:07.665236" + }, + "key": "/books/OL965004M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "publish_places": [ + "New York" + ], + "lccn": [ + "96001295" + ], + "pagination": "xi, 292 p. ;", + "created": { + "type": "/type/datetime", + "value": "2008-04-01T03:28:50.625462" + }, + "dewey_decimal_class": [ + "[Fic]" + ], + "notes": { + "type": "/type/text", + "value": "\"First published in 1995 by HarperCollins\"--verso t.p." + }, + "identifiers": { + "goodreads": [ + "1102517", + "535197" + ], + "librarything": [ + "10014" + ] + }, + "url": [ + "http://www.loc.gov/catdir/description/hc041/96001295.html" + ], + "isbn_10": [ + "0060273224", + "0060273232" + ], + "publish_date": "1995", + "works": [ + { + "key": "/works/OL15832982W" + } + ] + }, + { + "links": [ + { + "url": "http://www.loc.gov/catdir/description/hc041/96001295.html", + "title": "Publisher description" + } + ], + "covers": [ + 6796986 + ], + "local_id": [ + "urn:phillips:31867001228878", + "urn:sfpl:31223117573841", + "urn:sfpl:31223117573866", + "urn:sfpl:31223089678065", + "urn:sfpl:31223095696317", + "urn:sfpl:31223117573874", + "urn:sfpl:31223095696283", + "urn:sfpl:31223117573791", + "urn:sfpl:31223117573882", + "urn:sfpl:31223117573817", + "urn:sfpl:31223117573858", + "urn:sfpl:31223089678099", + "urn:sfpl:31223089678107", + "urn:sfpl:31223117573833", + "urn:sfpl:31223117573825", + "urn:sfpl:31223117573890" + ], + "lc_classifications": [ + "PZ7.N647 Sab 1995" + ], + "latest_revision": 5, + "ocaid": "sabrielnixg00nixg", + "description": { + "type": "/type/text", + "value": "Sabriel, daughter of the necromancer Abhorsen, must journey into the mysterious and magical Old Kingdom to rescue her father from the Land of the Dead." + }, + "languages": [ + { + "key": "/languages/eng" + } + ], + "source_records": [ + "ia:sabrielnixg00nixg", + "marc:marc_openlibraries_phillipsacademy/PANO_FOR_IA_05072019.mrc:13839588:1256", + "marc:marc_openlibraries_sanfranciscopubliclibrary/sfpl_chq_2018_12_24_run02.mrc:143114297:4621" + ], + "title": "Sabriel", + "edition_name": "1st American ed.", + "subjects": [ + "Fantasy" + ], + "publish_country": "nyu", + "by_statement": "Garth Nix", + "type": { + "key": "/type/edition" + }, + "revision": 5, + "publishers": [ + "HarperCollins" + ], + "ia_box_id": [ + "IA121618" + ], + "full_title": "Sabriel", + "last_modified": { + "type": "/type/datetime", + "value": "2019-07-22T10:06:26.311648" + }, + "key": "/books/OL24743307M", + "authors": [ + { + "key": "/authors/OL382982A" + } + ], + "publish_places": [ + "New York" + ], + "pagination": "xi, 292 p. ;", + "created": { + "type": "/type/datetime", + "value": "2011-07-07T16:30:28.384311" + }, + "lccn": [ + "96001295" + ], + "notes": { + "type": "/type/text", + "value": "\"First published in 1995 by HarperCollins\"--verso t.p." + }, + "number_of_pages": 292, + "dewey_decimal_class": [ + "[Fic]" + ], + "isbn_10": [ + "0060273224", + "0060273232" + ], + "publish_date": "1995", + "works": [ + { + "key": "/works/OL15832982W" + } + ] + } + ], + "links": { + "self": "/works/OL15832982W/editions.json", + "work": "/works/OL15832982W" + }, + "size": 15 +} diff --git a/fedireads/tests/test_connector_fedireads.py b/fedireads/tests/test_connector_fedireads.py new file mode 100644 index 00000000..b1da6f4c --- /dev/null +++ b/fedireads/tests/test_connector_fedireads.py @@ -0,0 +1,66 @@ +''' testing book data connectors ''' +from dateutil import parser +from django.test import TestCase +import json +import pathlib +import pytz + +from fedireads import models +from fedireads.connectors.fedireads_connector import Connector +from fedireads.connectors.abstract_connector import SearchResult, get_date + + +class FedireadsConnector(TestCase): + def setUp(self): + models.Connector.objects.create( + identifier='example.com', + connector_file='fedireads_connector', + base_url='https://example.com', + books_url='https://example.com', + covers_url='https://example.com/images/covers', + search_url='https://example.com/search?q=', + key_name='remote_id', + ) + self.connector = Connector('example.com') + + work_file = pathlib.Path(__file__).parent.joinpath( + 'data/fr_work.json') + edition_file = pathlib.Path(__file__).parent.joinpath( + 'data/fr_edition.json') + self.work_data = json.loads(work_file.read_bytes()) + self.edition_data = json.loads(edition_file.read_bytes()) + + + def test_is_work_data(self): + self.assertEqual(self.connector.is_work_data(self.work_data), True) + self.assertEqual(self.connector.is_work_data(self.edition_data), False) + + + def test_get_edition_from_work_data(self): + edition = self.connector.get_edition_from_work_data(self.work_data) + self.assertEqual(edition['url'], 'https://example.com/book/122') + + + def test_get_work_from_edition_data(self): + work = self.connector.get_work_from_edition_date(self.edition_data) + self.assertEqual(work['url'], 'https://example.com/book/121') + + + def test_format_search_result(self): + datafile = pathlib.Path(__file__).parent.joinpath('data/fr_search.json') + search_data = json.loads(datafile.read_bytes()) + results = self.connector.parse_search_data(search_data) + self.assertIsInstance(results, list) + + result = self.connector.format_search_result(results[0]) + self.assertIsInstance(result, SearchResult) + self.assertEqual(result.title, 'Jonathan Strange and Mr Norrell') + self.assertEqual(result.key, 'https://example.com/book/122') + self.assertEqual(result.author, 'Susanna Clarke') + self.assertEqual(result.year, 2017) + + + def test_get_date(self): + date = get_date(self.edition_data['published_date']) + expected = parser.parse("2017-05-10T00:00:00+00:00") + self.assertEqual(date, expected) diff --git a/fedireads/view_actions.py b/fedireads/view_actions.py index e490bf4a..85cf40af 100644 --- a/fedireads/view_actions.py +++ b/fedireads/view_actions.py @@ -13,6 +13,7 @@ from fedireads import forms, models, outgoing from fedireads import goodreads_import from fedireads.settings import DOMAIN from fedireads.views import get_user_from_username +from fedireads.books_manager import get_or_create_book def user_login(request): diff --git a/fedireads/views.py b/fedireads/views.py index e1009203..2acee9ed 100644 --- a/fedireads/views.py +++ b/fedireads/views.py @@ -6,7 +6,6 @@ from django.db.models import Avg, Q from django.http import HttpResponseBadRequest, HttpResponseNotFound,\ JsonResponse from django.core.exceptions import PermissionDenied -from django.shortcuts import redirect from django.template.response import TemplateResponse from django.views.decorators.csrf import csrf_exempt @@ -390,7 +389,7 @@ def edit_profile_page(request): def book_page(request, book_id, tab='friends'): ''' info about a book ''' - book = get_or_create_book(book_id) + book = models.Book.objects.select_subclasses().get(id=book_id) if is_api_request(request): return JsonResponse(activitypub.get_book(book)) From 4555d7713a717b873cb413fe200e195307dbee42 Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Sun, 10 May 2020 14:56:53 -0700 Subject: [PATCH 22/22] remove unneeded connector code --- fedireads/connectors/abstract_connector.py | 12 +++--------- fedireads/tests/test_connector_fedireads.py | 1 - 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/fedireads/connectors/abstract_connector.py b/fedireads/connectors/abstract_connector.py index 1e5c2665..64427117 100644 --- a/fedireads/connectors/abstract_connector.py +++ b/fedireads/connectors/abstract_connector.py @@ -267,19 +267,13 @@ def update_from_mappings(obj, data, mappings): if key == 'id': continue - if has_attr(obj, key): + try: + hasattr(obj, key) + except ValueError: obj.__setattr__(key, formatter(value)) return obj -def has_attr(obj, key): - ''' helper function to check if a model object has a key ''' - try: - return hasattr(obj, key) - except ValueError: - return False - - def get_date(date_string): ''' helper function to try to interpret dates ''' if not date_string: diff --git a/fedireads/tests/test_connector_fedireads.py b/fedireads/tests/test_connector_fedireads.py index b1da6f4c..2e0bfaf0 100644 --- a/fedireads/tests/test_connector_fedireads.py +++ b/fedireads/tests/test_connector_fedireads.py @@ -3,7 +3,6 @@ from dateutil import parser from django.test import TestCase import json import pathlib -import pytz from fedireads import models from fedireads.connectors.fedireads_connector import Connector