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/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/connectors/abstract_connector.py b/fedireads/connectors/abstract_connector.py index 1068fdeb..64427117 100644 --- a/fedireads/connectors/abstract_connector.py +++ b/fedireads/connectors/abstract_connector.py @@ -39,6 +39,7 @@ class AbstractConnector(ABC): for field in fields: setattr(self, field, getattr(info, field)) + def is_available(self): ''' check if you're allowed to use this connector ''' if self.max_query_count is not None: @@ -266,25 +267,25 @@ 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: 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/connectors/openlibrary.py b/fedireads/connectors/openlibrary.py index 40fe63f8..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): @@ -86,14 +86,13 @@ class Connector(AbstractConnector): def format_search_result(self, doc): - key = doc['key'] # build the absolute id from the openlibrary key - key = self.books_url + key + key = self.books_url + doc['key'] author = doc.get('author_name') or ['Unknown'] return SearchResult( doc.get('title'), key, - author[0], + ', '.join(author), doc.get('first_publish_year'), ) 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..580d978f 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) @@ -315,10 +310,10 @@ 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'].split('/')[-1] + book = activity['target']['id'] status_builder.create_tag(user, book, activity['object']['name']) 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 a3510397..32573414 100644 --- a/fedireads/models/book.py +++ b/fedireads/models/book.py @@ -3,9 +3,10 @@ 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 +from .base_model import FedireadsModel from fedireads.connectors.settings import CONNECTORS @@ -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/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/models/shelf.py b/fedireads/models/shelf.py index 32ea7273..ff625a09 100644 --- a/fedireads/models/shelf.py +++ b/fedireads/models/shelf.py @@ -1,7 +1,8 @@ ''' puttin' books on shelves ''' from django.db import models -from fedireads.utils.models import FedireadsModel +from fedireads import activitypub +from .base_model import FedireadsModel class Shelf(FedireadsModel): diff --git a/fedireads/models/status.py b/fedireads/models/status.py index 39cde35e..171d1a67 100644 --- a/fedireads/models/status.py +++ b/fedireads/models/status.py @@ -6,7 +6,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from model_utils.managers import InheritanceManager -from fedireads.utils.models import FedireadsModel +from fedireads import activitypub +from .base_model import FedireadsModel class Status(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,19 +102,17 @@ 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) 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 2f2c3dab..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): @@ -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/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) 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/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.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_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/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_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_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') diff --git a/fedireads/tests/test_connector_fedireads.py b/fedireads/tests/test_connector_fedireads.py new file mode 100644 index 00000000..2e0bfaf0 --- /dev/null +++ b/fedireads/tests/test_connector_fedireads.py @@ -0,0 +1,65 @@ +''' testing book data connectors ''' +from dateutil import parser +from django.test import TestCase +import json +import pathlib + +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/tests/test_connector_openlibrary.py b/fedireads/tests/test_connector_openlibrary.py new file mode 100644 index 00000000..68bace67 --- /dev/null +++ b/fedireads/tests/test_connector_openlibrary.py @@ -0,0 +1,79 @@ +''' 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.openlibrary import pick_default_edition +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') + 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): + ''' 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, 'https://openlibrary.org/works/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']) diff --git a/fedireads/tests/test_import_model.py b/fedireads/tests/test_import_model.py new file mode 100644 index 00000000..6fcca0cd --- /dev/null +++ b/fedireads/tests/test_import_model.py @@ -0,0 +1,112 @@ +''' testing models ''' +import datetime +from django.test import TestCase + +from fedireads import models + + +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' + unknown_read_data['Date 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_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) + + 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) + + def test_unread_reads(self): + expected = [] + actual = models.ImportItem.objects.get(index=3) + self.assertEqual(actual.reads, expected) + + + diff --git a/fedireads/tests/test_quotation.py b/fedireads/tests/test_quotation.py new file mode 100644 index 00000000..45f577a8 --- /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.quote, '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_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_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/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' + ) 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') + 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']) diff --git a/fedireads/urls.py b/fedireads/urls.py index 5a43b265..30793a50 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' @@ -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 828f2cff..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 @@ -239,7 +238,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 @@ -330,7 +329,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, @@ -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)) @@ -531,7 +530,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, 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, })