From d501e707eeca65d48291d5eebf8eef1e46c273ab Mon Sep 17 00:00:00 2001 From: Mouse Reeve Date: Fri, 6 Mar 2020 22:56:44 -0800 Subject: [PATCH 1/2] Store book data --- fedireads/activitypub/collection.py | 2 +- fedireads/activitypub/status.py | 2 +- .../migrations/0010_auto_20200307_0655.py | 190 ++++++++++++++++++ fedireads/models/__init__.py | 6 +- fedireads/models/book.py | 105 +++++----- fedireads/models/shelf.py | 42 ++++ fedireads/openlibrary.py | 62 ++++-- fedireads/outgoing.py | 4 +- fedireads/status.py | 7 +- fedireads/templates/author.html | 6 +- fedireads/templates/book.html | 8 +- fedireads/templates/snippets/authors.html | 2 +- fedireads/templates/snippets/book.html | 6 +- fedireads/templates/snippets/shelf.html | 6 +- fedireads/templates/snippets/status.html | 2 +- fedireads/templatetags/fr_display.py | 8 +- fedireads/utils/models.py | 1 + fedireads/views.py | 21 +- 18 files changed, 380 insertions(+), 100 deletions(-) create mode 100644 fedireads/migrations/0010_auto_20200307_0655.py create mode 100644 fedireads/models/shelf.py diff --git a/fedireads/activitypub/collection.py b/fedireads/activitypub/collection.py index 3c7019b4..3a4837ab 100644 --- a/fedireads/activitypub/collection.py +++ b/fedireads/activitypub/collection.py @@ -120,7 +120,7 @@ def get_add_remove(user, book, shelf, action='Add'): 'object': { # TODO: document?? 'type': 'Document', - 'name': book.data['title'], + 'name': book.title, 'url': book.openlibrary_key }, 'target': { diff --git a/fedireads/activitypub/status.py b/fedireads/activitypub/status.py index 4fff9458..5d6c0c75 100644 --- a/fedireads/activitypub/status.py +++ b/fedireads/activitypub/status.py @@ -16,7 +16,7 @@ def get_review_article(review): ''' a book review formatted for a non-fedireads isntance (mastodon) ''' status = get_status(review) name = 'Review of "%s" (%d stars): %s' % ( - review.book.data['title'], + review.book.title, review.rating, review.name ) diff --git a/fedireads/migrations/0010_auto_20200307_0655.py b/fedireads/migrations/0010_auto_20200307_0655.py new file mode 100644 index 00000000..7fe03cc6 --- /dev/null +++ b/fedireads/migrations/0010_auto_20200307_0655.py @@ -0,0 +1,190 @@ +# Generated by Django 3.0.3 on 2020-03-07 06:55 + +import datetime +from django.db import migrations, models +import django.db.models.deletion +import fedireads.utils.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('fedireads', '0009_status_published_date'), + ] + + operations = [ + migrations.CreateModel( + name='Edition', + fields=[ + ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Book')), + ('isbn', models.CharField(max_length=255, null=True, unique=True)), + ('oclc_number', models.CharField(max_length=255, null=True, unique=True)), + ('pages', models.IntegerField(null=True)), + ], + options={ + 'abstract': False, + }, + bases=('fedireads.book',), + ), + migrations.CreateModel( + name='Work', + fields=[ + ('book_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fedireads.Book')), + ('lccn', models.CharField(max_length=255, null=True, unique=True)), + ], + options={ + 'abstract': False, + }, + bases=('fedireads.book',), + ), + migrations.RemoveField( + model_name='author', + name='data', + ), + migrations.RemoveField( + model_name='book', + name='added_by', + ), + migrations.RemoveField( + model_name='book', + name='data', + ), + migrations.AddField( + model_name='author', + name='aliases', + field=fedireads.utils.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, size=None), + ), + migrations.AddField( + model_name='author', + name='bio', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='author', + name='born', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='author', + name='died', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='author', + name='first_name', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='author', + name='last_name', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='author', + name='name', + field=models.CharField(default='Unknown', max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='author', + name='wikipedia_link', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='description', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='book', + name='first_published_date', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='book', + name='language', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='last_sync_date', + field=models.DateTimeField(default=datetime.datetime.now), + ), + migrations.AddField( + model_name='book', + name='librarything_key', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='book', + name='local_edits', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='book', + name='local_key', + field=models.CharField(default=uuid.uuid4, max_length=255, unique=True), + ), + migrations.AddField( + model_name='book', + name='misc_identifiers', + field=fedireads.utils.fields.JSONField(null=True), + ), + migrations.AddField( + model_name='book', + name='origin', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='book', + name='published_date', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='book', + name='series', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='series_number', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='sort_title', + field=models.CharField(max_length=255, null=True), + ), + migrations.AddField( + model_name='book', + name='subtitle', + field=models.TextField(blank=True, null=True), + ), + migrations.AddField( + model_name='book', + name='sync', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='book', + name='title', + field=models.CharField(default='Unknown', max_length=255), + preserve_default=False, + ), + migrations.AlterField( + model_name='author', + name='openlibrary_key', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AlterField( + model_name='book', + name='openlibrary_key', + field=models.CharField(max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='book', + name='parent_work', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='fedireads.Work'), + ), + ] diff --git a/fedireads/models/__init__.py b/fedireads/models/__init__.py index 02154eeb..dc463a07 100644 --- a/fedireads/models/__init__.py +++ b/fedireads/models/__init__.py @@ -1,5 +1,5 @@ ''' bring all the models into the app namespace ''' -from .book import Shelf, ShelfBook, Book, Author -from .user import User, UserRelationship, FederatedServer +from .book import Book, Work, Edition, Author +from .shelf import Shelf, ShelfBook from .status import Status, Review, Favorite, Tag - +from .user import User, UserRelationship, FederatedServer diff --git a/fedireads/models/book.py b/fedireads/models/book.py index c067db07..3dc1f21c 100644 --- a/fedireads/models/book.py +++ b/fedireads/models/book.py @@ -1,68 +1,65 @@ ''' database schema for books and shelves ''' +from datetime import datetime from django.db import models +from uuid import uuid4 from fedireads.settings import DOMAIN -from fedireads.utils.fields import JSONField +from fedireads.utils.fields import JSONField, ArrayField from fedireads.utils.models import FedireadsModel -class Shelf(FedireadsModel): - name = models.CharField(max_length=100) - identifier = models.CharField(max_length=100) - user = models.ForeignKey('User', on_delete=models.PROTECT) - editable = models.BooleanField(default=True) - books = models.ManyToManyField( - 'Book', - symmetrical=False, - through='ShelfBook', - through_fields=('shelf', 'book') - ) - - @property - def absolute_id(self): - ''' use shelf identifier as absolute id ''' - base_path = self.user.absolute_id - model_name = type(self).__name__.lower() - return '%s/%s/%s' % (base_path, model_name, self.identifier) - - class Meta: - unique_together = ('user', 'identifier') - - -class ShelfBook(FedireadsModel): - # many to many join table for books and shelves - book = models.ForeignKey('Book', on_delete=models.PROTECT) - shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) - added_by = models.ForeignKey( - 'User', - blank=True, - null=True, - on_delete=models.PROTECT - ) - - class Meta: - unique_together = ('book', 'shelf') - - class Book(FedireadsModel): - ''' a non-canonical copy of a work (not book) from open library ''' - openlibrary_key = models.CharField(max_length=255, unique=True) - data = JSONField() + ''' a generic book, which can mean either an edition or a work ''' + # these identifiers apply to both works and editions + openlibrary_key = models.CharField(max_length=255, unique=True, null=True) + librarything_key = models.CharField(max_length=255, unique=True, null=True) + local_key = models.CharField(max_length=255, unique=True, default=uuid4) + misc_identifiers = JSONField(null=True) + + # info about where the data comes from and where/if to sync + origin = models.CharField(max_length=255, unique=True, null=True) + local_edits = models.BooleanField(default=False) + sync = models.BooleanField(default=True) + last_sync_date = models.DateTimeField(default=datetime.now) + + # TODO: edit history + + # book/work metadata + title = models.CharField(max_length=255) + sort_title = models.CharField(max_length=255, null=True) + subtitle = models.TextField(blank=True, null=True) + description = models.TextField(blank=True, null=True) + language = models.CharField(max_length=255, null=True) + series = models.CharField(max_length=255, blank=True, null=True) + series_number = models.CharField(max_length=255, blank=True, null=True) + # TODO: include an annotation about the type of authorship (ie, translator) authors = models.ManyToManyField('Author') # TODO: also store cover thumbnail cover = models.ImageField(upload_to='covers/', blank=True, null=True) + first_published_date = models.DateTimeField(null=True) + published_date = models.DateTimeField(null=True) shelves = models.ManyToManyField( 'Shelf', symmetrical=False, through='ShelfBook', through_fields=('book', 'shelf') ) - added_by = models.ForeignKey( - 'User', - blank=True, - null=True, - on_delete=models.PROTECT - ) + # TODO: why can't I just call this work???? + parent_work = models.ForeignKey('Work', on_delete=models.PROTECT, null=True) + + +class Work(Book): + ''' a work (an abstract concept of a book that manifests in an edition) ''' + # library of congress catalog control number + lccn = models.CharField(max_length=255, unique=True, null=True) + + +class Edition(Book): + ''' an edition of a book ''' + # these identifiers only apply to work + isbn = models.CharField(max_length=255, unique=True, null=True) + oclc_number = models.CharField(max_length=255, unique=True, null=True) + pages = models.IntegerField(null=True) @property def absolute_id(self): @@ -74,6 +71,14 @@ class Book(FedireadsModel): class Author(FedireadsModel): ''' copy of an author from OL ''' - openlibrary_key = models.CharField(max_length=255) - data = JSONField() + openlibrary_key = models.CharField(max_length=255, null=True, unique=True) + wikipedia_link = models.CharField(max_length=255, blank=True, null=True) + # idk probably other keys would be useful here? + born = models.DateTimeField(null=True) + died = models.DateTimeField(null=True) + name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255, null=True) + first_name = models.CharField(max_length=255, null=True) + aliases = ArrayField(models.CharField(max_length=255), blank=True) + bio = models.TextField(null=True, blank=True) diff --git a/fedireads/models/shelf.py b/fedireads/models/shelf.py new file mode 100644 index 00000000..cdd8701b --- /dev/null +++ b/fedireads/models/shelf.py @@ -0,0 +1,42 @@ +''' puttin' books on shelves ''' +from django.db import models + +from fedireads.utils.models import FedireadsModel + + +class Shelf(FedireadsModel): + name = models.CharField(max_length=100) + identifier = models.CharField(max_length=100) + user = models.ForeignKey('User', on_delete=models.PROTECT) + editable = models.BooleanField(default=True) + books = models.ManyToManyField( + 'Book', + symmetrical=False, + through='ShelfBook', + through_fields=('shelf', 'book') + ) + + @property + def absolute_id(self): + ''' use shelf identifier as absolute id ''' + base_path = self.user.absolute_id + model_name = type(self).__name__.lower() + return '%s/%s/%s' % (base_path, model_name, self.identifier) + + class Meta: + unique_together = ('user', 'identifier') + + +class ShelfBook(FedireadsModel): + # many to many join table for books and shelves + book = models.ForeignKey('Book', on_delete=models.PROTECT) + shelf = models.ForeignKey('Shelf', on_delete=models.PROTECT) + added_by = models.ForeignKey( + 'User', + blank=True, + null=True, + on_delete=models.PROTECT + ) + + class Meta: + unique_together = ('book', 'shelf') diff --git a/fedireads/openlibrary.py b/fedireads/openlibrary.py index 5a7fd799..8dfc879a 100644 --- a/fedireads/openlibrary.py +++ b/fedireads/openlibrary.py @@ -4,7 +4,7 @@ from django.core.files.base import ContentFile import re import requests -from fedireads.models import Author, Book +from fedireads import models from fedireads.settings import OL_URL @@ -29,24 +29,25 @@ def book_search(query): return results -def get_or_create_book(olkey, user=None, update=False): - ''' add a book by looking up its open library "work" key. I'm conflating - "book" and "work" here a bit; the table is called "book" in fedireads, but - in open library parlance, it's a "work," which is the canonical umbrella - item that contains all the editions ("book"s) ''' +def get_or_create_book(olkey, update=False): + ''' create a book or work ''' # check if this is in the format of an OL book identifier - if not re.match(r'^OL\d+W$', olkey): - raise ValueError('Invalid OpenLibrary work ID') + if re.match(r'^OL\d+W$', olkey): + model = models.Work + elif re.match(r'^OL\d+M$', olkey): + model = models.Edition + else: + raise ValueError('Invalid OpenLibrary ID') # get the existing entry from our db, if it exists try: - book = Book.objects.get(openlibrary_key=olkey) + book = model.objects.get(openlibrary_key=olkey) if not update: return book # we have the book, but still want to update it from OL except ObjectDoesNotExist: # no book was found, so we start creating a new one - book = Book(openlibrary_key=olkey) + book = model(openlibrary_key=olkey) # load the book json from openlibrary.org response = requests.get('%s/works/%s.json' % (OL_URL, olkey)) @@ -54,18 +55,30 @@ def get_or_create_book(olkey, user=None, update=False): response.raise_for_status() data = response.json() - book.data = data - - if user and user.is_authenticated: - book.added_by = user # great, we can update our book. + book.title = data['title'] + description = data.get('description') + if description: + if isinstance(description, dict): + description = description.get('value') + book.description = description + book.pages = data.get('pages') + #book.published_date = data.get('publish_date') + + # this book sure as heck better be an edition + if data.get('works'): + key = data.get('works')[0]['key'] + key = key.split('/')[-1] + work = get_or_create_book(key) + book.parent_work = work book.save() # we also need to know the author get the cover - for author_blob in data['authors']: + for author_blob in data.get('authors'): # this id starts as "/authors/OL1234567A" and we want just "OL1234567A" - author_id = author_blob['author']['key'] + author_blob = author_blob.get('author', author_blob) + author_id = author_blob['key'] author_id = author_id.split('/')[-1] book.authors.add(get_or_create_author(author_id)) @@ -92,7 +105,7 @@ def get_or_create_author(olkey, update=False): if not re.match(r'^OL\d+A$', olkey): raise ValueError('Invalid OpenLibrary author ID') try: - author = Author.objects.get(openlibrary_key=olkey) + author = models.Author.objects.get(openlibrary_key=olkey) if not update: return author except ObjectDoesNotExist: @@ -103,7 +116,20 @@ def get_or_create_author(olkey, update=False): response.raise_for_status() data = response.json() - author = Author(openlibrary_key=olkey, data=data) + author = models.Author(openlibrary_key=olkey) + bio = data.get('bio') + if bio: + if isinstance(bio, dict): + bio = bio.get('value') + author.bio = bio + name = data['name'] + author.name = name + # TODO this is making some BOLD assumption + author.last_name = name.split(' ')[-1] + author.first_name = ' '.join(name.split(' ')[:-1]) + #author.born = data.get('birth_date') + #author.died = data.get('death_date') author.save() + return author diff --git a/fedireads/outgoing.py b/fedireads/outgoing.py index f91975ff..8d5eb9f4 100644 --- a/fedireads/outgoing.py +++ b/fedireads/outgoing.py @@ -126,7 +126,7 @@ def handle_shelve(user, book, shelf): 'read': 'finished reading' }[shelf.identifier] name = user.name if user.name else user.localname - message = '%s %s %s' % (name, verb, book.data['title']) + message = '%s %s %s' % (name, verb, book.title) status = create_status(user, message, mention_books=[book]) activity = activitypub.get_status(status) @@ -150,7 +150,7 @@ def handle_unshelve(user, book, shelf): def handle_review(user, book, name, content, rating): ''' post a review ''' # validated and saves the review in the database so it has an id - review = create_review(user, book, name, content, rating) + review = create_review(user, book, name, content, rating, None) review_activity = activitypub.get_review(review) review_create_activity = activitypub.get_create(user, review_activity) diff --git a/fedireads/status.py b/fedireads/status.py index 0fe38b40..9f6a93e1 100644 --- a/fedireads/status.py +++ b/fedireads/status.py @@ -15,14 +15,17 @@ def create_review(user, possible_book, name, content, rating, published): # no ratings outside of 0-5 rating = rating if 0 <= rating <= 5 else 0 - return models.Review.objects.create( + review = models.Review( user=user, book=book, name=name, rating=rating, content=content, - published_date=published, ) + if published: + review.published_date = published + review.save() + return review def create_status(user, content, reply_parent=None, mention_books=None): diff --git a/fedireads/templates/author.html b/fedireads/templates/author.html index 191ad6ba..c89a94c5 100644 --- a/fedireads/templates/author.html +++ b/fedireads/templates/author.html @@ -3,9 +3,9 @@ {% block content %}
-

{{ author.data.name }}

- {% if author.data.bio %} -
{{ author.data.bio | author_bio }} +

{{ author.name }}

+ {% if author.bio %} +
{{ author.bio | author_bio }}
{% endif %} {% for book in books %} diff --git a/fedireads/templates/book.html b/fedireads/templates/book.html index 60c67921..73e3c12d 100644 --- a/fedireads/templates/book.html +++ b/fedireads/templates/book.html @@ -3,7 +3,7 @@ {% block content %}