mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-25 11:01:12 +00:00
Merge pull request #74 from mouse-reeve/book-data-overhaul
Store book data
This commit is contained in:
commit
143c496272
26 changed files with 585 additions and 200 deletions
|
@ -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': {
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
11
fedireads/books_manager.py
Normal file
11
fedireads/books_manager.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
''' select and call a connector for whatever book task needs doing '''
|
||||
from fedireads.connectors import OpenLibraryConnector
|
||||
|
||||
openlibrary = OpenLibraryConnector()
|
||||
def get_or_create_book(key):
|
||||
''' pull up a book record by whatever means possible '''
|
||||
return openlibrary.get_or_create_book(key)
|
||||
|
||||
def search(query):
|
||||
''' ya '''
|
||||
return openlibrary.search(query)
|
3
fedireads/connectors/__init__.py
Normal file
3
fedireads/connectors/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
''' bring connectors into the namespace '''
|
||||
from .settings import CONNECTORS
|
||||
from .openlibrary import OpenLibraryConnector
|
60
fedireads/connectors/abstract_connector.py
Normal file
60
fedireads/connectors/abstract_connector.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
''' functionality outline for a book data connector '''
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fedireads.connectors import CONNECTORS
|
||||
|
||||
|
||||
class AbstractConnector(ABC):
|
||||
''' generic book data connector '''
|
||||
|
||||
def __init__(self, connector_name):
|
||||
# load connector settings
|
||||
settings = CONNECTORS.get(connector_name)
|
||||
if not settings:
|
||||
raise ValueError('No connector with name "%s"' % connector_name)
|
||||
|
||||
try:
|
||||
self.url = settings['BASE_URL']
|
||||
self.covers_url = settings['COVERS_URL']
|
||||
self.db_field = settings['DB_KEY_FIELD']
|
||||
self.key_name = settings['KEY_NAME']
|
||||
except KeyError:
|
||||
raise KeyError('Invalid connector settings')
|
||||
# TODO: politeness settings
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def search(self, query):
|
||||
''' free text search '''
|
||||
# return list of search result objs
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def get_or_create_book(self, book_id):
|
||||
''' request and format a book given an identifier '''
|
||||
# return book model obj
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def get_or_create_author(self, book_id):
|
||||
''' request and format a book given an identifier '''
|
||||
# return book model obj
|
||||
pass
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def update_book(self, book_obj):
|
||||
''' sync a book with the canonical remote copy '''
|
||||
# return book model obj
|
||||
pass
|
||||
|
||||
|
||||
class SearchResult(object):
|
||||
''' standardized search result object '''
|
||||
def __init__(self, title, key, author, year):
|
||||
self.title = title
|
||||
self.key = key
|
||||
self.author = author
|
||||
self.year = year
|
138
fedireads/connectors/openlibrary.py
Normal file
138
fedireads/connectors/openlibrary.py
Normal file
|
@ -0,0 +1,138 @@
|
|||
''' openlibrary data connector '''
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.files.base import ContentFile
|
||||
import re
|
||||
import requests
|
||||
|
||||
from fedireads import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult
|
||||
|
||||
|
||||
class OpenLibraryConnector(AbstractConnector):
|
||||
''' instantiate a connector for OL '''
|
||||
def __init__(self):
|
||||
super().__init__('openlibrary')
|
||||
|
||||
|
||||
def search(self, query):
|
||||
''' query openlibrary search '''
|
||||
resp = requests.get('%s/search.json' % self.url, params={'q': query})
|
||||
if not resp.ok:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
results = []
|
||||
|
||||
for doc in data['docs'][:5]:
|
||||
key = doc['key']
|
||||
key = key.split('/')[-1]
|
||||
author = doc.get('author_name') or ['Unknown']
|
||||
results.append(SearchResult(
|
||||
doc.get('title'),
|
||||
key,
|
||||
author[0],
|
||||
doc.get('first_publish_year'),
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
def get_or_create_book(self, olkey):
|
||||
''' pull up a book record by whatever means possible '''
|
||||
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')
|
||||
|
||||
try:
|
||||
book = models.Book.objects.get(openlibrary_key=olkey)
|
||||
return book
|
||||
except ObjectDoesNotExist:
|
||||
# no book was found, so we start creating a new one
|
||||
book = model(openlibrary_key=olkey)
|
||||
|
||||
# load the book json from openlibrary.org
|
||||
response = requests.get('%s/works/%s.json' % (self.url, olkey))
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# 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 = self.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.get('authors'):
|
||||
# this id is "/authors/OL1234567A" and we want just "OL1234567A"
|
||||
author_blob = author_blob.get('author', author_blob)
|
||||
author_id = author_blob['key']
|
||||
author_id = author_id.split('/')[-1]
|
||||
book.authors.add(self.get_or_create_author(author_id))
|
||||
|
||||
if data.get('covers') and len(data['covers']):
|
||||
book.cover.save(*self.get_cover(data['covers'][0]), save=True)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
def get_or_create_author(self, olkey):
|
||||
''' load that author '''
|
||||
if not re.match(r'^OL\d+A$', olkey):
|
||||
raise ValueError('Invalid OpenLibrary author ID')
|
||||
try:
|
||||
author = models.Author.objects.get(openlibrary_key=olkey)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
response = requests.get('%s/authors/%s.json' % (self.url, olkey))
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
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
|
||||
|
||||
|
||||
def get_cover(self, cover_id):
|
||||
''' ask openlibrary for the cover '''
|
||||
# TODO: get medium and small versions
|
||||
image_name = '%s-M.jpg' % cover_id
|
||||
url = '%s/b/id/%s' % (self.covers_url, image_name)
|
||||
response = requests.get(url)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
image_content = ContentFile(requests.get(url).content)
|
||||
return [image_name, image_content]
|
||||
|
||||
|
||||
def update_book(self, book_obj):
|
||||
pass
|
28
fedireads/connectors/settings.py
Normal file
28
fedireads/connectors/settings.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
''' settings book data connectors '''
|
||||
CONNECTORS = {
|
||||
'openlibrary': {
|
||||
'KEY_NAME': 'olkey',
|
||||
'DB_KEY_FIELD': 'openlibrary_key',
|
||||
'POLITENESS_DELAY': 0,
|
||||
'MAX_DAILY_QUERIES': -1,
|
||||
'BASE_URL': 'https://openlibrary.org',
|
||||
'COVERS_URL': 'https://covers.openlibrary.org',
|
||||
},
|
||||
}
|
||||
|
||||
''' not implemented yet:
|
||||
'librarything': {
|
||||
'KEY_NAME': 'ltkey',
|
||||
'DB_KEY_FIELD': 'librarything_key',
|
||||
'POLITENESS_DELAY': 1,
|
||||
'MAX_DAILY_QUERIES': 1000,
|
||||
'BASE_URL': 'https://librarything.com',
|
||||
},
|
||||
'worldcat': {
|
||||
'KEY_NAME': 'ocn',
|
||||
'DB_KEY_FIELD': 'oclc_number',
|
||||
'POLITENESS_DELAY': 0,
|
||||
'MAX_DAILY_QUERIES': -1,
|
||||
'BASE_URL': 'https://worldcat.org',
|
||||
},
|
||||
'''
|
190
fedireads/migrations/0010_auto_20200307_0655.py
Normal file
190
fedireads/migrations/0010_auto_20200307_0655.py
Normal file
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
42
fedireads/models/shelf.py
Normal file
42
fedireads/models/shelf.py
Normal file
|
@ -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')
|
|
@ -1,109 +0,0 @@
|
|||
''' activitystream api and books '''
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.files.base import ContentFile
|
||||
import re
|
||||
import requests
|
||||
|
||||
from fedireads.models import Author, Book
|
||||
from fedireads.settings import OL_URL
|
||||
|
||||
|
||||
def book_search(query):
|
||||
''' look up a book '''
|
||||
response = requests.get('%s/search.json' % OL_URL, params={'q': query})
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
results = []
|
||||
|
||||
for doc in data['docs'][:5]:
|
||||
key = doc['key']
|
||||
key = key.split('/')[-1]
|
||||
author = doc.get('author_name') or ['Unknown']
|
||||
results.append({
|
||||
'title': doc.get('title'),
|
||||
'olkey': key,
|
||||
'year': doc.get('first_publish_year'),
|
||||
'author': author[0],
|
||||
})
|
||||
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) '''
|
||||
# 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')
|
||||
|
||||
# get the existing entry from our db, if it exists
|
||||
try:
|
||||
book = Book.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)
|
||||
|
||||
# load the book json from openlibrary.org
|
||||
response = requests.get('%s/works/%s.json' % (OL_URL, olkey))
|
||||
if not response.ok:
|
||||
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.save()
|
||||
|
||||
# we also need to know the author get the cover
|
||||
for author_blob in data['authors']:
|
||||
# this id starts as "/authors/OL1234567A" and we want just "OL1234567A"
|
||||
author_id = author_blob['author']['key']
|
||||
author_id = author_id.split('/')[-1]
|
||||
book.authors.add(get_or_create_author(author_id))
|
||||
|
||||
if data.get('covers') and len(data['covers']):
|
||||
book.cover.save(*get_cover(data['covers'][0]), save=True)
|
||||
|
||||
return book
|
||||
|
||||
|
||||
def get_cover(cover_id):
|
||||
''' ask openlibrary for the cover '''
|
||||
# TODO: get medium and small versions
|
||||
image_name = '%s-M.jpg' % cover_id
|
||||
url = 'https://covers.openlibrary.org/b/id/%s' % image_name
|
||||
response = requests.get(url)
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
image_content = ContentFile(requests.get(url).content)
|
||||
return [image_name, image_content]
|
||||
|
||||
|
||||
def get_or_create_author(olkey, update=False):
|
||||
''' load that author '''
|
||||
if not re.match(r'^OL\d+A$', olkey):
|
||||
raise ValueError('Invalid OpenLibrary author ID')
|
||||
try:
|
||||
author = Author.objects.get(openlibrary_key=olkey)
|
||||
if not update:
|
||||
return author
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
response = requests.get('%s/authors/%s.json' % (OL_URL, olkey))
|
||||
if not response.ok:
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
author = Author(openlibrary_key=olkey, data=data)
|
||||
author.save()
|
||||
return author
|
||||
|
|
@ -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)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
''' Handle user activity '''
|
||||
from fedireads import models
|
||||
from fedireads.openlibrary import get_or_create_book
|
||||
from fedireads.books_manager import get_or_create_book
|
||||
from fedireads.sanitize_html import InputHtmlParser
|
||||
from django.db import IntegrityError
|
||||
|
||||
|
@ -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):
|
||||
|
|
|
@ -3,9 +3,9 @@
|
|||
{% block content %}
|
||||
<div id="content">
|
||||
<div>
|
||||
<h2>{{ author.data.name }}</h2>
|
||||
{% if author.data.bio %}
|
||||
<blockquote>{{ author.data.bio | author_bio }}
|
||||
<h2>{{ author.name }}</h2>
|
||||
{% if author.bio %}
|
||||
<blockquote>{{ author.bio | author_bio }}
|
||||
</blockquote>
|
||||
{% endif %}
|
||||
{% for book in books %}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
{% block content %}
|
||||
<div id="sidebar">
|
||||
<div>
|
||||
<h2><q>{{ book.data.title }}</q> and You</h2>
|
||||
<h2><q>{{ book.title }}</q> and You</h2>
|
||||
<p>{% if shelf %}On shelf <q>{{ shelf.name }}</q>{% endif %}</p>
|
||||
{% include 'snippets/shelve-button.html' with book=book pulldown=True %}
|
||||
|
||||
|
@ -27,13 +27,15 @@
|
|||
|
||||
<div id="content">
|
||||
<div>
|
||||
<h2><q>{{ book.data.title }}</q> by
|
||||
<h2><q>{{ book.title }}</q> by
|
||||
{% include 'snippets/authors.html' with book=book %}</h2>
|
||||
{% if book.parent_work %}<p>Edition of <a href="/book/{{ book.parent_work.openlibrary_key }}">{{ book.parent_work.title }}</a></p>{% endif %}
|
||||
<div class="book-preview">
|
||||
|
||||
{% include 'snippets/book_cover.html' with book=book size=large %}
|
||||
<p>{{ active_tab }} rating: {{ rating | stars }}</p>
|
||||
{% if description %}
|
||||
<blockquote>{{ book.data.description | description }}</blockquote>
|
||||
<blockquote>{{ book.description | description }}</blockquote>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div id="tag-cloud">
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<h1>Search results</h1>
|
||||
{% for result in results %}
|
||||
<div>
|
||||
<a href="/book/{{ result.olkey }}">{{ result.title }}</a> by {{ result.author }} ({{ result.year }})
|
||||
<a href="/book/{{ result.key }}">{{ result.title }}</a> by {{ result.author }} ({{ result.year }})
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
|
@ -1 +1 @@
|
|||
by <a href="/author/{{ book.authors.first.openlibrary_key }}" class="author">{{ book.authors.first.data.name }}</a>
|
||||
<a href="/author/{{ book.authors.first.openlibrary_key }}" class="author">{{ book.authors.first.name }}</a>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{% load fr_display %}
|
||||
{% include 'snippets/book_cover.html' with book=book %}
|
||||
<p class="title">
|
||||
<a href="/book/{{ book.openlibrary_key }}">{{ book.data.title }}</a>
|
||||
<a href="/book/{{ book.openlibrary_key }}">{{ book.title }}</a>
|
||||
</p>
|
||||
<p>
|
||||
{% include 'snippets/authors.html' with book=book %}
|
||||
by {% include 'snippets/authors.html' with book=book %}
|
||||
</p>
|
||||
|
||||
{% if rating %}
|
||||
|
@ -12,7 +12,7 @@
|
|||
{% endif %}
|
||||
|
||||
{% if description %}
|
||||
<blockquote>{{ book.data.description | description }}</blockquote>
|
||||
<blockquote>{{ book.description | description }}</blockquote>
|
||||
{% endif %}
|
||||
|
||||
{% include 'snippets/shelve-button.html' with book=book pulldown=shelf_pulldown %}
|
||||
|
|
|
@ -33,19 +33,19 @@
|
|||
{% include 'snippets/book_cover.html' with book=book %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/book/{{ book.openlibrary_key }}">{{ book.data.title }}</a>
|
||||
<a href="/book/{{ book.openlibrary_key }}">{{ book.title }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ book.authors.first.data.name }}
|
||||
</td>
|
||||
<td>
|
||||
{{ book.data.first_publish_date }}
|
||||
{{ book.first_publish_date }}
|
||||
</td>
|
||||
<td>
|
||||
{{ book.added_date | naturalday }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://openlibrary.org{{ book.data.key }}" target="_blank">OpenLibrary</a>
|
||||
<a href="https://openlibrary.org{{ book.key }}" target="_blank">OpenLibrary</a>
|
||||
</td>
|
||||
{% if ratings %}
|
||||
<td>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% load fr_display %}
|
||||
<div class="update">
|
||||
{% if activity.status_type == 'Review' %}
|
||||
{% include 'snippets/status_banner.html' with content="reviewed <i>"|add:activity.book.data.title|add:"</i>" %}
|
||||
{% include 'snippets/status_banner.html' with content="reviewed <i>"|add:activity.book.title|add:"</i>" %}
|
||||
<div class="book-preview review">
|
||||
{% include 'snippets/book.html' with book=activity.book size=large %}
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ def dict_key(d, k):
|
|||
'''Returns the given key from a dictionary.'''
|
||||
return d.get(k) or 0
|
||||
|
||||
|
||||
@register.filter(name='stars')
|
||||
def stars(number):
|
||||
''' turn integers into stars '''
|
||||
|
@ -20,16 +21,19 @@ def stars(number):
|
|||
number = 0
|
||||
return ('★' * number) + '☆' * (5 - number)
|
||||
|
||||
|
||||
@register.filter(name='description')
|
||||
def description_format(description):
|
||||
''' handle the various OL description formats '''
|
||||
if isinstance(description, dict) and 'value' in description:
|
||||
description = description['value']
|
||||
if not description:
|
||||
return ''
|
||||
|
||||
if '----------' in description:
|
||||
description = description.split('----------')[0]
|
||||
|
||||
return description.strip()
|
||||
|
||||
|
||||
@register.filter(name='author_bio')
|
||||
def bio_format(bio):
|
||||
''' clean up OL author bios '''
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
''' base model with default fields '''
|
||||
from django.db import models
|
||||
|
||||
from fedireads.settings import DOMAIN
|
||||
|
|
|
@ -5,7 +5,7 @@ from django.shortcuts import redirect
|
|||
from django.template.response import TemplateResponse
|
||||
import re
|
||||
|
||||
from fedireads import forms, models, openlibrary, outgoing
|
||||
from fedireads import forms, models, books_manager, outgoing
|
||||
from fedireads.views import get_user_from_username
|
||||
|
||||
|
||||
|
@ -150,8 +150,8 @@ def search(request):
|
|||
results = [outgoing.handle_account_search(query)]
|
||||
template = 'user_results.html'
|
||||
else:
|
||||
# just send the question over to openlibrary for book search
|
||||
results = openlibrary.book_search(query)
|
||||
# just send the question over to book search
|
||||
results = books_manager.search(query)
|
||||
template = 'book_results.html'
|
||||
|
||||
return TemplateResponse(request, template, {'results': results})
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.http import HttpResponseNotFound
|
|||
from django.shortcuts import redirect
|
||||
from django.template.response import TemplateResponse
|
||||
|
||||
from fedireads import forms, models, openlibrary, incoming
|
||||
from fedireads import forms, models, books_manager, incoming
|
||||
from fedireads.settings import DOMAIN
|
||||
|
||||
|
||||
|
@ -188,28 +188,35 @@ def edit_profile_page(request, username):
|
|||
@login_required
|
||||
def book_page(request, book_identifier, tab='friends'):
|
||||
''' info about a book '''
|
||||
book = openlibrary.get_or_create_book(book_identifier)
|
||||
book = books_manager.get_or_create_book(book_identifier)
|
||||
|
||||
user_reviews = models.Review.objects.filter(user=request.user, book=book).all()
|
||||
if isinstance(book, models.Work):
|
||||
book_reviews = models.Review.objects.filter(
|
||||
Q(book=book) | Q(book__parent_work=book),
|
||||
)
|
||||
else:
|
||||
book_reviews = models.Review.objects.filter(book=book)
|
||||
|
||||
user_reviews = book_reviews.filter(
|
||||
user=request.user,
|
||||
).all()
|
||||
|
||||
if tab == 'friends':
|
||||
reviews = models.Review.objects.filter(
|
||||
reviews = book_reviews.filter(
|
||||
Q(user__followers=request.user, privacy='public') | \
|
||||
Q(user=request.user) | \
|
||||
Q(mention_users=request.user),
|
||||
book=book,
|
||||
)
|
||||
elif tab == 'local':
|
||||
reviews = models.Review.objects.filter(
|
||||
reviews = book_reviews.filter(
|
||||
Q(privacy='public') | \
|
||||
Q(mention_users=request.user),
|
||||
user__local=True,
|
||||
book=book,
|
||||
)
|
||||
else:
|
||||
reviews = models.Review.objects.filter(
|
||||
reviews = book_reviews.filter(
|
||||
Q(privacy='public') | \
|
||||
Q(mention_users=request.user),
|
||||
book=book,
|
||||
)
|
||||
|
||||
try:
|
||||
|
@ -251,7 +258,7 @@ def book_page(request, book_identifier, tab='friends'):
|
|||
def author_page(request, author_identifier):
|
||||
''' landing page for an author '''
|
||||
try:
|
||||
author = models.Author.objects.get(openlibrary_key=author_identifier)
|
||||
author = models.Author.objects.get(books_manager_key=author_identifier)
|
||||
except ValueError:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
from fedireads.models import User
|
||||
from fedireads.openlibrary import get_or_create_book
|
||||
from fedireads.books_manager import get_or_create_book
|
||||
|
||||
User.objects.create_user('mouse', 'mouse.reeve@gmail.com', 'password123')
|
||||
User.objects.create_user('rat', 'rat@rat.com', 'ratword')
|
||||
|
|
Loading…
Reference in a new issue