Merge pull request #162 from mouse-reeve/fedireads_connector

Fedireads connector
This commit is contained in:
Mouse Reeve 2020-05-10 15:07:14 -07:00 committed by GitHub
commit 64903157ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 715 additions and 475 deletions

View file

@ -1,6 +1,6 @@
''' bring activitypub functions into the namespace '''
from .actor import get_actor
from .book import get_book
from .book import get_book, get_author
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

View file

@ -1,25 +1,23 @@
''' federate book data '''
from fedireads.settings import DOMAIN
def get_book(book):
def get_book(book, recursive=True):
''' activitypub serialize a book '''
fields = [
'title',
'sort_title',
'subtitle',
'isbn_13',
'oclc_number',
'openlibrary_key',
'librarything_key',
'fedireads_key',
'lccn',
'oclc_number',
'pages',
'physical_format',
'misc_identifiers',
'source_url',
'description',
'languages',
'series',
@ -30,21 +28,28 @@ def get_book(book):
'physical_format',
]
book_type = type(book).__name__
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Document',
'book_type': type(book).__name__,
'book_type': book_type,
'name': book.title,
'url': book.absolute_id,
'authors': [get_author(a) for a in book.authors.all()],
'authors': [a.absolute_id for a in book.authors.all()],
'first_published_date': book.first_published_date.isoformat() if \
book.first_published_date else None,
'published_date': book.published_date.isoformat() if \
book.published_date else None,
'parent_work': book.parent_work.absolute_id if \
hasattr(book, 'parent_work') else None,
}
if recursive:
if book_type == 'Edition':
activity['work'] = get_book(book.parent_work, recursive=False)
else:
editions = book.edition_set.order_by('default')
activity['editions'] = [
get_book(b, recursive=False) for b in editions]
for field in fields:
if hasattr(book, field):
activity[field] = book.__getattribute__(field)
@ -63,7 +68,21 @@ def get_book(book):
def get_author(author):
''' serialize an author '''
return {
'name': author.name,
fields = [
'name',
'born',
'died',
'aliases',
'bio'
'openlibrary_key',
'wikipedia_link',
]
activity = {
'@context': 'https://www.w3.org/ns/activitystreams',
'url': author.absolute_id,
'type': 'Person',
}
for field in fields:
if hasattr(author, field):
activity[field] = author.__getattribute__(field)
return activity

View file

@ -1,31 +1,100 @@
''' select and call a connector for whatever book task needs doing '''
import importlib
from requests import HTTPError
from fedireads import models
import importlib
from urllib.parse import urlparse
from fedireads import models, settings
from fedireads.tasks import app
def get_or_create_book(key):
def get_or_create_book(value, key='id', connector_id=None):
''' pull up a book record by whatever means possible '''
try:
book = models.Book.objects.select_subclasses().get(
fedireads_key=key
)
book = models.Book.objects.select_subclasses().filter(
**{key: value}
).first()
if book:
if not isinstance(book, models.Edition):
return book.default_edition
return book
except models.Book.DoesNotExist:
pass
connector = get_connector()
book = connector.get_or_create_book(key)
if key == 'remote_id':
book = get_by_absolute_id(value, models.Book)
if book:
return book
if connector_id:
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
else:
connector = get_or_create_connector(value)
book = connector.get_or_create_book(value)
load_more_data.delay(book.id)
return book
def get_or_create_connector(remote_id):
''' get the connector related to the author's server '''
url = urlparse(remote_id)
identifier = url.netloc
if not identifier:
raise ValueError('Invalid remote id')
try:
connector_info = models.Connector.objects.get(identifier=identifier)
except models.Connector.DoesNotExist:
connector_info = models.Connector.objects.create(
identifier=identifier,
connector_file='fedireads_connector',
base_url='https://%s' % identifier,
books_url='https://%s/book' % identifier,
covers_url='https://%s/images/covers' % identifier,
search_url='https://%s/search?q=' % identifier,
key_name='remote_id',
priority=3
)
return load_connector(connector_info)
def get_by_absolute_id(absolute_id, model):
''' generalized function to get from a model with a remote_id field '''
if not absolute_id:
return None
# check if it's a remote status
try:
return model.objects.get(remote_id=absolute_id)
except model.DoesNotExist:
pass
url = urlparse(absolute_id)
if url.netloc != settings.DOMAIN:
return None
# try finding a local status with that id
local_id = absolute_id.split('/')[-1]
try:
if hasattr(model.objects, 'select_subclasses'):
possible_match = model.objects.select_subclasses().get(id=local_id)
else:
possible_match = model.objects.get(id=local_id)
except model.DoesNotExist:
return None
# make sure it's not actually a remote status with an id that
# clashes with a local id
if possible_match.absolute_id == absolute_id:
return possible_match
return None
@app.task
def load_more_data(book_id):
''' background the work of getting all 10,000 editions of LoTR '''
book = models.Book.objects.select_subclasses().get(id=book_id)
connector = get_connector(book)
connector = load_connector(book.connector)
connector.expand_book_data(book)
@ -35,7 +104,10 @@ def search(query):
dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year)
result_index = set()
for connector in get_connectors():
result_set = connector.search(query)
try:
result_set = connector.search(query)
except HTTPError:
continue
result_set = [r for r in result_set \
if dedup_slug(r) not in result_index]
@ -49,6 +121,13 @@ def search(query):
return results
def local_search(query):
''' only look at local search results '''
connector = load_connector(models.Connector.objects.get(local=True))
return connector.search(query)
def first_search_result(query):
''' search until you find a result that fits '''
for connector in get_connectors():
@ -58,10 +137,10 @@ def first_search_result(query):
return None
def update_book(book):
def update_book(book, data=None):
''' re-sync with the original data source '''
connector = get_connector(book)
connector.update_book(book)
connector = load_connector(book.connector)
connector.update_book(book, data=data)
def get_connectors():
@ -70,18 +149,6 @@ def get_connectors():
return [load_connector(c) for c in connectors_info]
def get_connector(book=None):
''' pick a book data connector '''
if book and book.connector:
connector_info = book.connector
else:
# only select from external connectors
connector_info = models.Connector.objects.filter(
local=False
).order_by('priority').first()
return load_connector(connector_info)
def load_connector(connector_info):
''' instantiate the connector class '''
connector = importlib.import_module(

View file

@ -2,6 +2,9 @@
from abc import ABC, abstractmethod
from dateutil import parser
import pytz
import requests
from django.db import transaction
from fedireads import models
@ -14,34 +17,233 @@ class AbstractConnector(ABC):
info = models.Connector.objects.get(identifier=identifier)
self.connector = info
self.base_url = info.base_url
self.books_url = info.books_url
self.covers_url = info.covers_url
self.search_url = info.search_url
self.key_name = info.key_name
self.max_query_count = info.max_query_count
self.name = info.name
self.local = info.local
self.book_mappings = {}
self.key_mappings = {
'isbn_13': ('isbn_13', None),
'isbn_10': ('isbn_10', None),
'oclc_numbers': ('oclc_number', None),
'lccn': ('lccn', None),
}
fields = [
'base_url',
'books_url',
'covers_url',
'search_url',
'key_name',
'max_query_count',
'name',
'identifier',
'local'
]
for field in fields:
setattr(self, field, getattr(info, field))
def is_available(self):
''' check if you're allowed to use this connector '''
if self.connector.max_query_count is not None:
if self.connector.query_count >= self.connector.max_query_count:
if self.max_query_count is not None:
if self.connector.query_count >= self.max_query_count:
return False
return True
@abstractmethod
def search(self, query):
''' free text search '''
# return list of search result objs
resp = requests.get(
'%s%s' % (self.search_url, query),
headers={
'Accept': 'application/json; charset=utf-8',
},
)
if not resp.ok:
resp.raise_for_status()
data = resp.json()
results = []
for doc in self.parse_search_data(data)[:10]:
results.append(self.format_search_result(doc))
return results
def get_or_create_book(self, remote_id):
''' pull up a book record by whatever means possible '''
# try to load the book
book = models.Book.objects.select_subclasses().filter(
remote_id=remote_id
).first()
if book:
if isinstance(book, models.Work):
return book.default_edition
return book
# no book was found, so we start creating a new one
data = get_data(remote_id)
work = None
edition = None
if self.is_work_data(data):
work_data = data
# if we requested a work and there's already an edition, we're set
work = self.match_from_mappings(work_data)
if work and work.default_edition:
return work.default_edition
# no such luck, we need more information.
try:
edition_data = self.get_edition_from_work_data(work_data)
except KeyError:
# hack: re-use the work data as the edition data
# this is why remote ids aren't necessarily unique
edition_data = data
else:
edition_data = data
edition = self.match_from_mappings(edition_data)
# no need to figure out about the work if we already know about it
if edition and edition.parent_work:
return edition
# no such luck, we need more information.
try:
work_data = self.get_work_from_edition_date(edition_data)
except KeyError:
# remember this hack: re-use the work data as the edition data
work_data = data
# at this point, we need to figure out the work, edition, or both
# atomic so that we don't save a work with no edition for vice versa
with transaction.atomic():
if not work:
work_key = work_data.get('url')
work = self.create_book(work_key, work_data, models.Work)
if not edition:
ed_key = edition_data.get('url')
edition = self.create_book(ed_key, edition_data, models.Edition)
edition.default = True
edition.parent_work = work
edition.save()
# now's our change to fill in author gaps
if not edition.authors and work.authors:
edition.authors.set(work.authors.all())
edition.author_text = work.author_text
edition.save()
return edition
def create_book(self, remote_id, data, model):
''' create a work or edition from data '''
book = model.objects.create(
remote_id=remote_id,
title=data['title'],
connector=self.connector,
)
return self.update_book_from_data(book, data)
def update_book_from_data(self, book, data, update_cover=True):
''' for creating a new book or syncing with data '''
book = update_from_mappings(book, data, self.book_mappings)
for author in self.get_authors_from_data(data):
book.authors.add(author)
book.author_text = ', '.join(a.name for a in book.authors.all())
book.save()
if not update_cover:
return book
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def update_book(self, book, data=None):
''' load new data '''
if not book.sync and not book.sync_cover:
return
if not data:
key = getattr(book, self.key_name)
data = self.load_book_data(key)
if book.sync:
book = self.update_book_from_data(
book, data, update_cover=book.sync_cover)
else:
cover = self.get_cover_from_data(data)
if cover:
book.cover.save(*cover, save=True)
return book
def match_from_mappings(self, data):
''' try to find existing copies of this book using various keys '''
keys = [
('openlibrary_key', models.Book),
('librarything_key', models.Book),
('goodreads_key', models.Book),
('lccn', models.Work),
('isbn_10', models.Edition),
('isbn_13', models.Edition),
('oclc_number', models.Edition),
('asin', models.Edition),
]
noop = lambda x: x
for key, model in keys:
formatter = None
if key in self.key_mappings:
key, formatter = self.key_mappings[key]
if not formatter:
formatter = noop
value = data.get(key)
if not value:
continue
value = formatter(value)
match = model.objects.select_subclasses().filter(
**{key: value}).first()
if match:
return match
@abstractmethod
def get_or_create_book(self, book_id):
''' request and format a book given an identifier '''
# return book model obj
def is_work_data(self, data):
''' differentiate works and editions '''
@abstractmethod
def get_edition_from_work_data(self, data):
''' every work needs at least one edition '''
@abstractmethod
def get_work_from_edition_date(self, data):
''' every edition needs a work '''
@abstractmethod
def get_authors_from_data(self, data):
''' load author data '''
@abstractmethod
def get_cover_from_data(self, data):
''' load cover '''
@abstractmethod
def parse_search_data(self, data):
''' turn the result json from a search into a list '''
@abstractmethod
def format_search_result(self, search_result):
''' create a SearchResult obj from json '''
@abstractmethod
@ -49,18 +251,6 @@ class AbstractConnector(ABC):
''' get more info on a book '''
@abstractmethod
def get_or_create_author(self, book_id):
''' request and format a book given an identifier '''
# return book model obj
@abstractmethod
def update_book(self, book_obj):
''' sync a book with the canonical remote copy '''
# return book model obj
def update_from_mappings(obj, data, mappings):
''' assign data to model with mappings '''
noop = lambda x: x
@ -91,20 +281,35 @@ def has_attr(obj, key):
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:
return None
class SearchResult:
def get_data(url):
''' wrapper for request.get '''
resp = requests.get(
url,
headers={
'Accept': 'application/json; charset=utf-8',
},
)
if not resp.ok:
resp.raise_for_status()
data = resp.json()
return data
class SearchResult(object):
''' standardized search result object '''
def __init__(self, title, key, author, year, raw_data):
def __init__(self, title, key, author, year):
self.title = title
self.key = key
self.author = author
self.year = year
self.raw_data = raw_data
def __repr__(self):
return "<SearchResult key={!r} title={!r} author={!r}>".format(

View file

@ -1,101 +1,68 @@
''' using another fedireads instance as a source of book data '''
from uuid import uuid4
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.base import ContentFile
import requests
from fedireads import models
from .abstract_connector import AbstractConnector
from .abstract_connector import update_from_mappings, get_date
from .abstract_connector import AbstractConnector, SearchResult
from .abstract_connector import update_from_mappings, get_date, get_data
class Connector(AbstractConnector):
def search(self, query):
''' right now you can't search fedireads, but... '''
resp = requests.get(
'%s%s' % (self.search_url, query),
headers={
'Accept': 'application/activity+json; charset=utf-8',
},
)
if not resp.ok:
resp.raise_for_status()
return resp.json()
''' interact with other instances '''
def __init__(self, identifier):
super().__init__(identifier)
self.book_mappings = self.key_mappings.copy()
self.book_mappings.update({
'published_date': ('published_date', get_date),
'first_published_date': ('first_published_date', get_date),
})
def get_or_create_book(self, fedireads_key):
''' pull up a book record by whatever means possible '''
try:
book = models.Book.objects.select_subclasses().get(
fedireads_key=fedireads_key
)
return book
except ObjectDoesNotExist:
if self.model.is_self:
# we can't load a book from a remote server, this is it
return None
# no book was found, so we start creating a new one
book = models.Book(fedireads_key=fedireads_key)
def is_work_data(self, data):
return data['book_type'] == 'Work'
def update_book(self, book):
''' add remote data to a local book '''
fedireads_key = book.fedireads_key
response = requests.get(
'%s/%s' % (self.base_url, fedireads_key),
headers={
'Accept': 'application/activity+json; charset=utf-8',
},
)
def get_edition_from_work_data(self, data):
return data['editions'][0]
def get_work_from_edition_date(self, data):
return data['work']
def get_authors_from_data(self, data):
for author_url in data.get('authors', []):
yield self.get_or_create_author(author_url)
def get_cover_from_data(self, data):
cover_data = data.get('attachment')
if not cover_data:
return None
cover_url = cover_data[0].get('url')
response = requests.get(cover_url)
if not response.ok:
response.raise_for_status()
data = response.json()
# great, we can update our book.
mappings = {
'published_date': ('published_date', get_date),
'first_published_date': ('first_published_date', get_date),
}
book = update_from_mappings(book, data, mappings)
if not book.source_url:
book.source_url = response.url
if not book.connector:
book.connector = self.connector
book.save()
if data.get('parent_work'):
work = self.get_or_create_book(data.get('parent_work'))
book.parent_work = work
for author_blob in data.get('authors', []):
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 book.sync_cover and data.get('covers') and data['covers']:
book.cover.save(*get_cover(data['covers'][0]), save=True)
return book
image_name = str(uuid4()) + cover_url.split('.')[-1]
image_content = ContentFile(response.content)
return [image_name, image_content]
def get_or_create_author(self, fedireads_key):
def get_or_create_author(self, remote_id):
''' load that author '''
try:
return models.Author.objects.get(fedireads_key=fedireads_key)
return models.Author.objects.get(remote_id=remote_id)
except ObjectDoesNotExist:
pass
resp = requests.get('%s/authors/%s.json' % (self.url, fedireads_key))
if not resp.ok:
resp.raise_for_status()
data = resp.json()
data = get_data(remote_id)
# ingest a new author
author = models.Author(fedireads_key=fedireads_key)
author = models.Author(remote_id=remote_id)
mappings = {
'born': ('born', get_date),
'died': ('died', get_date),
@ -106,11 +73,14 @@ class Connector(AbstractConnector):
return author
def get_cover(cover_url):
''' ask openlibrary for the cover '''
image_name = cover_url.split('/')[-1]
response = requests.get(cover_url)
if not response.ok:
response.raise_for_status()
image_content = ContentFile(response.content)
return [image_name, image_content]
def parse_search_data(self, data):
return data
def format_search_result(self, search_result):
return SearchResult(**search_result)
def expand_book_data(self, book):
# TODO
pass

View file

@ -3,184 +3,105 @@ import re
import requests
from django.core.files.base import ContentFile
from django.db import transaction
from fedireads import models
from .abstract_connector import AbstractConnector, SearchResult
from .abstract_connector import update_from_mappings, get_date
from .abstract_connector import update_from_mappings
from .abstract_connector import get_date, get_data
from .openlibrary_languages import languages
class Connector(AbstractConnector):
''' instantiate a connector for OL '''
def __init__(self, identifier):
super().__init__(identifier)
get_first = lambda a: a[0]
self.book_mappings = {
self.key_mappings = {
'isbn_13': ('isbn_13', get_first),
'isbn_10': ('isbn_10', get_first),
'oclc_numbers': ('oclc_number', get_first),
'lccn': ('lccn', get_first),
}
self.book_mappings = self.key_mappings.copy()
self.book_mappings.update({
'publish_date': ('published_date', get_date),
'first_publish_date': ('first_published_date', get_date),
'description': ('description', get_description),
'isbn_13': ('isbn_13', get_first),
'oclc_numbers': ('oclc_number', get_first),
'lccn': ('lccn', get_first),
'languages': ('languages', get_languages),
'number_of_pages': ('pages', None),
'series': ('series', get_first),
}
super().__init__(identifier)
})
def search(self, query):
''' query openlibrary search '''
resp = requests.get(
'%s%s' % (self.search_url, query),
headers={
'Accept': 'application/json; charset=utf-8',
},
)
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'),
doc
))
return results
def is_work_data(self, data):
return not re.match(r'^OL\d+M$', data['key'])
def get_or_create_book(self, olkey):
''' pull up a book record by whatever means possible.
if you give a work key, it should give you the default edition,
annotated with work data. '''
book = models.Book.objects.select_subclasses().filter(
openlibrary_key=olkey
).first()
if book:
if isinstance(book, models.Work):
return book.default_edition
return book
# no book was found, so we start creating a new one
if re.match(r'^OL\d+W$', olkey):
with transaction.atomic():
# create both work and a default edition
work_data = self.load_book_data(olkey)
work = self.create_book(olkey, work_data, models.Work)
edition_options = self.load_edition_data(olkey).get('entries')
edition_data = pick_default_edition(edition_options)
if not edition_data:
# hack: re-use the work data as the edition data
edition_data = work_data
key = edition_data.get('key').split('/')[-1]
edition = self.create_book(key, edition_data, models.Edition)
edition.default = True
edition.parent_work = work
edition.save()
else:
with transaction.atomic():
edition_data = self.load_book_data(olkey)
edition = self.create_book(olkey, edition_data, models.Edition)
work_data = edition_data.get('works')
if not work_data:
# hack: we're re-using the edition data as the work data
work_key = olkey
else:
work_key = work_data[0]['key'].split('/')[-1]
work = models.Work.objects.filter(
openlibrary_key=work_key
).first()
if not work:
work_data = self.load_book_data(work_key)
work = self.create_book(work_key, work_data, models.Work)
edition.parent_work = work
edition.save()
if not edition.authors and work.authors:
edition.authors.set(work.authors.all())
edition.author_text = ', '.join(a.name for a in edition.authors)
return edition
def get_edition_from_work_data(self, data):
try:
key = data['key']
except KeyError:
return False
url = '%s/%s/editions' % (self.books_url, key)
data = get_data(url)
return pick_default_edition(data['entries'])
def create_book(self, key, data, model):
''' create a work or edition from data '''
book = model.objects.create(
openlibrary_key=key,
title=data['title'],
connector=self.connector,
)
return self.update_book_from_data(book, data)
def update_book_from_data(self, book, data):
''' updaet a book model instance from ol data '''
# populate the simple data fields
update_from_mappings(book, data, self.book_mappings)
book.save()
authors = self.get_authors_from_data(data)
for author in authors:
book.authors.add(author)
if authors:
book.author_text = ', '.join(a.name for a in authors)
if data.get('covers'):
book.cover.save(*self.get_cover(data['covers'][0]), save=True)
return book
def update_book(self, book):
''' load new data '''
if not book.sync and not book.sync_cover:
return
data = self.load_book_data(book.openlibrary_key)
if book.sync_cover and data.get('covers'):
book.cover.save(*self.get_cover(data['covers'][0]), save=True)
if book.sync:
book = self.update_book_from_data(book, data)
return book
def get_work_from_edition_date(self, data):
try:
key = data['works'][0]['key']
except (IndexError, KeyError):
return False
url = '%s/%s' % (self.books_url, key)
return get_data(url)
def get_authors_from_data(self, data):
''' parse author json and load or create authors '''
authors = []
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)
# this id is "/authors/OL1234567A" and we want just "OL1234567A"
author_id = author_blob['key'].split('/')[-1]
authors.append(self.get_or_create_author(author_id))
return authors
yield self.get_or_create_author(author_id)
def load_book_data(self, olkey):
''' query openlibrary for data on a book '''
response = requests.get('%s/works/%s.json' % (self.books_url, olkey))
def get_cover_from_data(self, data):
''' ask openlibrary for the cover '''
if not data.get('covers'):
return None
cover_id = data.get('covers')[0]
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()
data = response.json()
return data
image_content = ContentFile(response.content)
return [image_name, image_content]
def parse_search_data(self, data):
return data.get('docs')
def format_search_result(self, doc):
key = doc['key']
# build the absolute id from the openlibrary key
key = self.books_url + key
author = doc.get('author_name') or ['Unknown']
return SearchResult(
doc.get('title'),
key,
author[0],
doc.get('first_publish_year'),
)
def load_edition_data(self, olkey):
''' query openlibrary for editions of a work '''
response = requests.get(
'%s/works/%s/editions.json' % (self.books_url, olkey))
if not response.ok:
response.raise_for_status()
data = response.json()
return data
url = '%s/works/%s/editions.json' % (self.books_url, olkey)
return get_data(url)
def expand_book_data(self, book):
@ -209,11 +130,9 @@ class Connector(AbstractConnector):
except models.Author.DoesNotExist:
pass
response = requests.get('%s/authors/%s.json' % (self.base_url, olkey))
if not response.ok:
response.raise_for_status()
url = '%s/authors/%s.json' % (self.base_url, olkey)
data = get_data(url)
data = response.json()
author = models.Author(openlibrary_key=olkey)
mappings = {
'birth_date': ('born', get_date),
@ -221,8 +140,8 @@ class Connector(AbstractConnector):
'bio': ('bio', get_description),
}
author = update_from_mappings(author, data, mappings)
# TODO this is making some BOLD assumption
name = data.get('name')
# TODO this is making some BOLD assumption
if name:
author.last_name = name.split(' ')[-1]
author.first_name = ' '.join(name.split(' ')[:-1])
@ -231,18 +150,6 @@ class Connector(AbstractConnector):
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(response.content)
return [image_name, image_content]
def get_description(description_blob):
''' descriptions can be a string or a dict '''
if isinstance(description_blob, dict):

View file

@ -23,9 +23,9 @@ class Connector(AbstractConnector):
SearchVector('isbn_10', weight='A') +\
SearchVector('openlibrary_key', weight='B') +\
SearchVector('goodreads_key', weight='B') +\
SearchVector('source_url', weight='B') +\
SearchVector('asin', weight='B') +\
SearchVector('oclc_number', weight='B') +\
SearchVector('remote_id', weight='B') +\
SearchVector('description', weight='C') +\
SearchVector('series', weight='C')
).filter(search=query)
@ -34,39 +34,49 @@ class Connector(AbstractConnector):
search_results = []
for book in results[:10]:
search_results.append(
SearchResult(
book.title,
book.fedireads_key,
book.author_text,
book.published_date.year if book.published_date else None,
None
)
self.format_search_result(book)
)
return search_results
def get_or_create_book(self, fedireads_key):
def format_search_result(self, book):
return SearchResult(
book.title,
book.absolute_id,
book.author_text,
book.published_date.year if book.published_date else None,
)
def get_or_create_book(self, book_id):
''' since this is querying its own data source, it can only
get a book, not load one from an external source '''
try:
return models.Book.objects.select_subclasses().get(
fedireads_key=fedireads_key
id=book_id
)
except ObjectDoesNotExist:
return None
def get_or_create_author(self, fedireads_key):
''' load that author '''
try:
return models.Author.objects.get(fedreads_key=fedireads_key)
except ObjectDoesNotExist:
pass
def update_book(self, book_obj):
def is_work_data(self, data):
pass
def get_edition_from_work_data(self, data):
pass
def get_work_from_edition_date(self, data):
pass
def get_authors_from_data(self, data):
return None
def get_cover_from_data(self, data):
return None
def parse_search_data(self, data):
''' it's already in the right format, don't even worry about it '''
return data
def expand_book_data(self, book):
pass

View file

@ -109,7 +109,6 @@ class EditionForm(ModelForm):
'subjects',# TODO
'subject_places',# TODO
'source_url',
'connector',
]

View file

@ -10,7 +10,7 @@ from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.views.decorators.csrf import csrf_exempt
import requests
from fedireads import models, outgoing
from fedireads import books_manager, models, outgoing
from fedireads import status as status_builder
from fedireads.remote_user import get_or_create_remote_user
from fedireads.tasks import app
@ -63,7 +63,7 @@ def shared_inbox(request):
},
'Update': {
'Person': None,# TODO: handle_update_user
'Document': None# TODO: handle_update_book
'Document': handle_update_book,
},
}
activity_type = activity['type']
@ -320,3 +320,18 @@ def handle_tag(activity):
if not user.local:
book = activity['target']['id'].split('/')[-1]
status_builder.create_tag(user, book, activity['object']['name'])
@app.task
def handle_update_book(activity):
''' a remote instance changed a book (Document) '''
document = activity['object']
# check if we have their copy and care about their updates
book = models.Book.objects.select_subclasses().filter(
remote_id=document['url'],
sync=True,
).first()
if not book:
return
books_manager.update_book(book, data=document)

View file

@ -0,0 +1,41 @@
# Generated by Django 3.0.3 on 2020-05-04 01:54
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0036_auto_20200503_2007'),
]
operations = [
migrations.RemoveField(
model_name='author',
name='fedireads_key',
),
migrations.RemoveField(
model_name='book',
name='fedireads_key',
),
migrations.RemoveField(
model_name='book',
name='source_url',
),
migrations.AddField(
model_name='author',
name='last_sync_date',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name='author',
name='sync',
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name='book',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 3.0.3 on 2020-05-09 19:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fedireads', '0037_auto_20200504_0154'),
]
operations = [
migrations.AddField(
model_name='author',
name='remote_id',
field=models.CharField(max_length=255, null=True),
),
]

View file

@ -1,6 +1,4 @@
''' database schema for books and shelves '''
from uuid import uuid4
from django.utils import timezone
from django.db import models
from model_utils.managers import InheritanceManager
@ -50,15 +48,14 @@ class Connector(FedireadsModel):
class Book(FedireadsModel):
''' a generic book, which can mean either an edition or a work '''
remote_id = models.CharField(max_length=255, null=True)
# these identifiers apply to both works and editions
fedireads_key = models.CharField(max_length=255, unique=True, default=uuid4)
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
librarything_key = models.CharField(max_length=255, blank=True, null=True)
goodreads_key = models.CharField(max_length=255, blank=True, null=True)
misc_identifiers = JSONField(null=True)
# info about where the data comes from and where/if to sync
source_url = models.CharField(max_length=255, unique=True, null=True)
sync = models.BooleanField(default=True)
sync_cover = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
@ -95,9 +92,10 @@ class Book(FedireadsModel):
@property
def absolute_id(self):
''' constructs the absolute reference to any db object '''
if self.sync and self.remote_id:
return self.remote_id
base_path = 'https://%s' % DOMAIN
model_name = type(self).__name__.lower()
return '%s/book/%s' % (base_path, self.openlibrary_key)
return '%s/book/%d' % (base_path, self.id)
def save(self, *args, **kwargs):
''' can't be abstract for query reasons, but you shouldn't USE it '''
@ -130,6 +128,7 @@ class Edition(Book):
''' an edition of a book '''
# default -> this is what gets displayed for a work
default = models.BooleanField(default=False)
# these identifiers only apply to editions, not works
isbn_10 = models.CharField(max_length=255, blank=True, null=True)
isbn_13 = models.CharField(max_length=255, blank=True, null=True)
@ -151,8 +150,10 @@ class Edition(Book):
class Author(FedireadsModel):
''' copy of an author from OL '''
fedireads_key = models.CharField(max_length=255, unique=True, default=uuid4)
remote_id = models.CharField(max_length=255, null=True)
openlibrary_key = models.CharField(max_length=255, blank=True, null=True)
sync = models.BooleanField(default=True)
last_sync_date = models.DateTimeField(default=timezone.now)
wikipedia_link = models.CharField(max_length=255, blank=True, null=True)
# idk probably other keys would be useful here?
born = models.DateTimeField(blank=True, null=True)

View file

@ -7,7 +7,7 @@ from django.http import HttpResponseNotFound, JsonResponse
from django.views.decorators.csrf import csrf_exempt
import requests
from fedireads import activitypub
from fedireads import activitypub, books_manager
from fedireads import models
from fedireads.broadcast import broadcast
from fedireads.status import create_review, create_status
@ -260,9 +260,10 @@ def handle_comment(user, book, content):
user, book, builder, fr_serializer, ap_serializer, content)
def handle_status(user, book, \
def handle_status(user, book_id, \
builder, fr_serializer, ap_serializer, *args):
''' generic handler for statuses '''
book = books_manager.get_or_create_book(book_id)
status = builder(user, book, *args)
activity = fr_serializer(status)
@ -286,7 +287,7 @@ def handle_tag(user, book, name):
def handle_untag(user, book, name):
''' tag a book '''
book = models.Book.objects.get(fedireads_key=book)
book = models.Book.objects.get(id=book)
tag = models.Tag.objects.get(name=name, book=book, user=user)
tag_activity = activitypub.get_remove_tag(tag)
tag.delete()

View file

@ -2,22 +2,20 @@
from django.db import IntegrityError
from fedireads import models
from fedireads.books_manager import get_or_create_book
from fedireads.books_manager import get_or_create_book, get_by_absolute_id
from fedireads.sanitize_html import InputHtmlParser
def create_review_from_activity(author, activity):
''' parse an activity json blob into a status '''
book_id = activity['inReplyToBook']
book_id = book_id.split('/')[-1]
book = get_or_create_book(book_id, key='remote_id')
name = activity.get('name')
rating = activity.get('rating')
content = activity.get('content')
published = activity.get('published')
remote_id = activity['id']
book = get_or_create_book(book_id)
review = create_review(author, book, name, content, rating)
review.published_date = published
review.remote_id = remote_id
@ -58,8 +56,8 @@ def create_review(user, book, name, content, rating):
def create_quotation_from_activity(author, activity):
''' parse an activity json blob into a status '''
book = activity['inReplyToBook']
book = book.split('/')[-1]
book_id = activity['inReplyToBook']
book = get_or_create_book(book_id, key='remote_id')
quote = activity.get('quote')
content = activity.get('content')
published = activity.get('published')
@ -72,10 +70,9 @@ def create_quotation_from_activity(author, activity):
return quotation
def create_quotation(user, possible_book, content, quote):
def create_quotation(user, book, content, quote):
''' a quotation has been added '''
# throws a value error if the book is not found
book = get_or_create_book(possible_book)
content = sanitize(content)
quote = sanitize(quote)
@ -87,11 +84,10 @@ def create_quotation(user, possible_book, content, quote):
)
def create_comment_from_activity(author, activity):
''' parse an activity json blob into a status '''
book = activity['inReplyToBook']
book = book.split('/')[-1]
book_id = activity['inReplyToBook']
book = get_or_create_book(book_id, key='remote_id')
content = activity.get('content')
published = activity.get('published')
remote_id = activity['id']
@ -103,10 +99,9 @@ def create_comment_from_activity(author, activity):
return comment
def create_comment(user, possible_book, content):
def create_comment(user, book, content):
''' a book comment has been added '''
# throws a value error if the book is not found
book = get_or_create_book(possible_book)
content = sanitize(content)
return models.Comment.objects.create(
@ -170,34 +165,6 @@ def get_favorite(absolute_id):
return get_by_absolute_id(absolute_id, models.Favorite)
def get_by_absolute_id(absolute_id, model):
''' generalized function to get from a model with a remote_id field '''
if not absolute_id:
return None
# check if it's a remote status
try:
return model.objects.get(remote_id=absolute_id)
except model.DoesNotExist:
pass
# try finding a local status with that id
local_id = absolute_id.split('/')[-1]
try:
if hasattr(model.objects, 'select_subclasses'):
possible_match = model.objects.select_subclasses().get(id=local_id)
else:
possible_match = model.objects.get(id=local_id)
except model.DoesNotExist:
return None
# make sure it's not actually a remote status with an id that
# clashes with a local id
if possible_match.absolute_id == absolute_id:
return possible_match
return None
def create_status(user, content, reply_parent=None, mention_books=None,
remote_id=None):
''' a status update '''
@ -224,7 +191,7 @@ def create_status(user, content, reply_parent=None, mention_books=None,
def create_tag(user, possible_book, name):
''' add a tag to a book '''
book = get_or_create_book(possible_book)
book = get_or_create_book(possible_book, key='remote_id')
try:
tag = models.Tag.objects.create(name=name, book=book, user=user)

View file

@ -16,7 +16,7 @@
<div class="book-grid row shrink wrap">
{% for book in books %}
<div class="book-preview">
<a href="{{ book.absolute_id }}">
<a href="/book/{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book %}
</a>
{% include 'snippets/shelve_button.html' with book=book %}

View file

@ -6,7 +6,7 @@
{% include 'snippets/book_titleby.html' with book=book %}
{% if request.user.is_authenticated %}
<a href="{{ book.fedireads_key }}/edit" class="edit-link">edit
<a href="{{ book.id }}/edit" class="edit-link">edit
<span class="icon icon-pencil">
<span class="hidden-text">Edit Book</span>
</span>
@ -22,7 +22,7 @@
{% include 'snippets/shelve_button.html' %}
{% if request.user.is_authenticated and not book.cover %}
<form name="add-cover" method="POST" action="/upload_cover/{{book.id}}" enctype="multipart/form-data">
<form name="add-cover" method="POST" action="/upload_cover/{{ book.id }}" enctype="multipart/form-data">
{% csrf_token %}
{{ cover_form.as_p }}
<button type="submit">Add cover</button>
@ -57,7 +57,7 @@
<h3>Tags</h3>
<form name="tag" action="/tag/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.fedireads_key }}">
<input type="hidden" name="book" value="{{ book.id }}">
<input type="text" name="name">
<button type="submit">Add tag</button>
</form>

View file

@ -3,19 +3,25 @@
<div class="content-container">
<h2>Search results</h2>
{% for result_set in results %}
{% if result_set.results %}
<section>
{% if not result_set.connector.local %}
<h3>
Results from <a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name }}</a>
Results from <a href="{{ result_set.connector.base_url }}" target="_blank">{% if result_set.connector.name %}{{ result_set.connector.name }}{% else %}{{ result_set.connector.identifier }}{% endif %}</a>
</h3>
{% endif %}
{% for result in result_set.results %}
<div>
<a href="/book/{{ result.key }}">{{ result.title }}</a> by {{ result.author }} ({{ result.year }})
<form action="/resolve_book" method="POST">
{% csrf_token %}
<input type="hidden" name="remote_id" value="{{ result.key }}">
<button type="submit">{{ result.title }} by {{ result.author }} ({{ result.year }})</button>
</form>
</div>
{% endfor %}
</section>
{% endif %}
{% endfor %}
</div>
{% endblock %}

View file

@ -6,7 +6,7 @@
<div class="book-grid row wrap shrink">
{% for book in books %}
<div class="cover-container">
<a href="{{ book.absolute_id }}">
<a href="/book/{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book %}
</a>
{% include 'snippets/shelve_button.html' with book=book %}

View file

@ -4,7 +4,7 @@
<div class="content-container">
<h2>
Edit "{{ book.title }}"
<a href="/book/{{ book.fedireads_key }}">
<a href="/book/{{ book.id }}">
<span class="edit-link icon icon-close">
<span class="hidden-text">Close</span>
</span>
@ -40,8 +40,8 @@
<h3>Book Identifiers</h2>
<div>
<p><label for="id_isbn">ISBN 13:</label> {{ form.isbn_13 }} </p>
<p><label for="id_fedireads_key">Fedireads key:</label> {{ form.fedireads_key }} </p>
<p><label for="id_isbn_13">ISBN 13:</label> {{ form.isbn_13 }} </p>
<p><label for="id_isbn_10">ISBN 10:</label> {{ form.isbn_10 }} </p>
<p><label for="id_openlibrary_key">Openlibrary key:</label> {{ form.openlibrary_key }} </p>
<p><label for="id_librarything_key">Librarything key:</label> {{ form.librarything_key }} </p>
<p><label for="id_goodreads_key">Goodreads key:</label> {{ form.goodreads_key }} </p>

View file

@ -2,11 +2,11 @@
{% load fr_display %}
{% block content %}
<div class="content-container">
<h2>Editions of <a href="/book/{{ work.fedireads_key }}">"{{ work.title }}"</a></h2>
<h2>Editions of <a href="/book/{{ work.id }}">"{{ work.title }}"</a></h2>
<ol class="book-grid row wrap">
{% for book in editions %}
<li class="book-preview">
<a href="{{ book.absolute_id }}">
<a href="/book/{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book %}
</a>
{% include 'snippets/shelve_button.html' with book=book %}

View file

@ -44,7 +44,7 @@
<tr>
<td>
{% if item.book %}
<a href="{{ item.book.absolute_id }}">
<a href="/book/{{ item.book.id }}">
{% include 'snippets/book_cover.html' with book=item.book size='small' %}
</a>
{% endif %}

View file

@ -1 +1 @@
<a href="/author/{{ book.authors.first.fedireads_key }}" class="author">{{ book.authors.first.name }}</a>
<a href="/author/{{ book.authors.first.id }}" class="author">{{ book.authors.first.name }}</a>

View file

@ -1,5 +1,5 @@
<span class="title">
<a href="/book/{{ book.fedireads_key }}">{{ book.title }}</a>
<a href="/book/{{ book.id }}">{{ book.title }}</a>
</span>
{% if book.authors %}
<span class="author">

View file

@ -37,7 +37,7 @@
<h2>
{% include 'snippets/avatar.html' with user=user %}
Your thoughts on
a <a href="/book/{{ book.fedireads_key }}">{{ book.title }}</a>
a <a href="/book/{{ book.id }}">{{ book.title }}</a>
by {% include 'snippets/authors.html' with book=book %}
</h2>

View file

@ -3,13 +3,13 @@
<div class="tabs secondary">
<div class="tab active" data-id="tab-review-{{ book.id }}" data-category="tab-option-{{ book.id }}">
<a href="{{ book.absolute_id }}/review" onclick="tabChange(event)">Review</a>
<a href="/book/{{ book.id }}/review" onclick="tabChange(event)">Review</a>
</div>
<div class="tab" data-id="tab-comment-{{ book.id }}" data-category="tab-option-{{ book.id }}">
<a href="{{ book.absolute_id }}/comment" onclick="tabChange(event)">Comment</a>
<a href="/book/{{ book.id }}/comment" onclick="tabChange(event)">Comment</a>
</div>
<div class="tab" data-id="tab-quotation-{{ book.id }}" data-category="tab-option-{{ book.id }}">
<a href="{{ book.absolute_id }}/quotation" onclick="tabChange(event)">Quote</a>
<a href="/book/{{ book.id }}/quotation" onclick="tabChange(event)">Quote</a>
</div>
</div>
@ -21,7 +21,7 @@
{% endif %}
<form class="tab-option-{{ book.id }} review-form" name="review" action="/review/" method="post" id="tab-review-{{ book.id }}">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.fedireads_key }}">
<input type="hidden" name="book" value="{{ book.id }}">
{% include 'snippets/rate_form.html' with book=book %}
{{ review_form.as_p }}
<button type="submit">post review</button>
@ -29,14 +29,14 @@
<form class="hidden tab-option-{{ book.id }} review-form" name="comment" action="/comment/" method="post" id="tab-comment-{{ book.id }}">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.fedireads_key }}">
<input type="hidden" name="book" value="{{ book.id }}">
{{ comment_form.as_p }}
<button type="submit">post comment</button>
</form>
<form class="hidden tab-option-{{ book.id }} review-form quote-form" name="quotation" action="/quotate/" method="post" id="tab-quotation-{{ book.id }}">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.fedireads_key }}">
<input type="hidden" name="book" value="{{ book.id }}">
{{ quotation_form.as_p }}
<button typr="submit">post quote</button>
</form>

View file

@ -4,7 +4,7 @@
{% for i in '12345'|make_list %}
<form name="rate" action="/rate/" method="POST" onsubmit="return rate_stars(event)">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.fedireads_key }}">
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="rating" value="{{ forloop.counter }}">
<button type="submit" class="icon icon-star-{% if book|rating:user < forloop.counter %}empty{% else %}full{% endif %}">
<span class="hidden-text">{{ forloop.counter }} star{{ forloop.counter | pluralize }}</span>

View file

@ -39,7 +39,7 @@
{% include 'snippets/book_cover.html' with book=book size="small" %}
</td>
<td>
<a href="/book/{{ book.fedireads_key }}">{{ book.title }}</a>
<a href="/book/{{ book.id }}">{{ book.title }}</a>
</td>
<td>
{{ book.authors.first.name }}

View file

@ -6,13 +6,13 @@
{% if status.status_type == 'Update' %}
{{ status.content | safe }}
{% elif status.status_type == 'Review' and not status.name and not status.content%}
rated <a href="{{ status.book.absolute_id }}">{{ status.book.title }}</a>
rated <a href="/book/{{ status.book.id }}">{{ status.book.title }}</a>
{% elif status.status_type == 'Review' %}
reviewed <a href="{{ status.book.absolute_id }}">{{ status.book.title }}</a>
reviewed <a href="/book/{{ status.book.id }}">{{ status.book.title }}</a>
{% elif status.status_type == 'Comment' %}
commented on <a href="{{ status.book.absolute_id }}">{{ status.book.title }}</a>
commented on <a href="/book/{{ status.book.id }}">{{ status.book.title }}</a>
{% elif status.status_type == 'Quotation' %}
quoted <a href="{{ status.book.absolute_id }}">{{ status.book.title }}</a>
quoted <a href="/book/{{ status.book.id }}">{{ status.book.title }}</a>
{% elif status.status_type == 'Boost' %}
boosted
{% elif status.reply_parent %}

View file

@ -3,14 +3,14 @@
{% if tag.identifier in user_tags %}
<form class="tag-form" name="tag" action="/untag/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.fedireads_key }}">
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="name" value="{{ tag.name }}">
<button type="submit">x</button>
</form>
{% else %}
<form class="tag-form" name="tag" action="/tag/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.fedireads_key }}">
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="name" value="{{ tag.name }}">
<button type="submit">+</button>
</form>

View file

@ -6,7 +6,7 @@
<div class="book-grid row wrap shrink">
{% for book in books.all %}
<div class="cover-container">
<a href="{{ book.absolute_id }}">
<a href="/book/{{ book.id }}">
{% include 'snippets/book_cover.html' with book=book %}
</a>
{% include 'snippets/rate_action.html' with user=request.user book=book %}

View file

@ -11,7 +11,7 @@ localname_regex = r'(?P<username>[\w\-_]+)'
user_path = r'^user/%s' % username_regex
local_user_path = r'^user/%s' % localname_regex
status_path = r'%s/(status|review|comment)/(?P<status_id>\d+)' % local_user_path
book_path = r'^book/(?P<book_identifier>[\w\-]+)'
book_path = r'^book/(?P<book_id>\d+)'
handler404 = 'fedireads.views.not_found_page'
handler500 = 'fedireads.views.server_error_page'
@ -63,19 +63,21 @@ urlpatterns = [
re_path(r'%s/edit/?$' % book_path, views.edit_book_page),
re_path(r'^editions/(?P<work_id>\d+)/?$', views.editions_page),
re_path(r'^author/(?P<author_identifier>[\w\-]+)/?$', views.author_page),
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page),
re_path(r'^shelf/%s/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % username_regex, views.shelf_page),
re_path(r'^shelf/%s/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % localname_regex, views.shelf_page),
re_path(r'^search/?$', views.search),
# internal action endpoints
re_path(r'^logout/?$', actions.user_logout),
re_path(r'^user-login/?$', actions.user_login),
re_path(r'^register/?$', actions.register),
re_path(r'^edit_profile/?$', actions.edit_profile),
re_path(r'^search/?$', actions.search),
re_path(r'^import_data/?', actions.import_data),
re_path(r'^resolve_book/?', actions.resolve_book),
re_path(r'^edit_book/(?P<book_id>\d+)/?', actions.edit_book),
re_path(r'^upload_cover/(?P<book_id>\d+)/?', actions.upload_cover),

View file

@ -1,6 +1,5 @@
''' views for actions you can take in the application '''
from io import BytesIO, TextIOWrapper
import re
from PIL import Image
from django.contrib.auth import authenticate, login, logout
@ -10,7 +9,7 @@ from django.http import HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
from django.template.response import TemplateResponse
from fedireads import forms, models, books_manager, outgoing
from fedireads import forms, models, outgoing
from fedireads import goodreads_import
from fedireads.settings import DOMAIN
from fedireads.views import get_user_from_username
@ -116,6 +115,13 @@ def edit_profile(request):
return redirect('/user/%s' % request.user.localname)
def resolve_book(request):
''' figure out the local path to a book from a remote_id '''
remote_id = request.POST.get('remote_id')
book = get_or_create_book(remote_id, key='remote_id')
return redirect('/book/%d' % book.id)
@login_required
def edit_book(request, book_id):
''' edit a book cool '''
@ -133,7 +139,7 @@ def edit_book(request, book_id):
form.save()
outgoing.handle_update_book(request.user, book)
return redirect('/book/%s' % book.fedireads_key)
return redirect('/book/%s' % book.id)
@login_required
@ -157,7 +163,7 @@ def upload_cover(request, book_id):
book.save()
outgoing.handle_update_book(request.user, book)
return redirect('/book/%s' % book.fedireads_key)
return redirect('/book/%s' % book.id)
@login_required
@ -190,27 +196,25 @@ def shelve(request):
def rate(request):
''' just a star rating for a book '''
form = forms.RatingForm(request.POST)
book_identifier = request.POST.get('book')
book_id = request.POST.get('book')
# TODO: better failure behavior
if not form.is_valid():
return redirect('/book/%s' % book_identifier)
return redirect('/book/%s' % book_id)
rating = form.cleaned_data.get('rating')
# throws a value error if the book is not found
book = get_or_create_book(book_identifier)
outgoing.handle_rate(request.user, book, rating)
return redirect('/book/%s' % book_identifier)
outgoing.handle_rate(request.user, book_id, rating)
return redirect('/book/%s' % book_id)
@login_required
def review(request):
''' create a book review '''
form = forms.ReviewForm(request.POST)
book_identifier = request.POST.get('book')
# TODO: better failure behavior
book_id = request.POST.get('book')
if not form.is_valid():
return redirect('/book/%s' % book_identifier)
return redirect('/book/%s' % book_id)
# TODO: validation, htmlification
name = form.cleaned_data.get('name')
@ -221,42 +225,39 @@ def review(request):
except ValueError:
rating = None
# throws a value error if the book is not found
book = get_or_create_book(book_identifier)
outgoing.handle_review(request.user, book, name, content, rating)
return redirect('/book/%s' % book_identifier)
outgoing.handle_review(request.user, book_id, name, content, rating)
return redirect('/book/%s' % book_id)
@login_required
def quotate(request):
''' create a book quotation '''
form = forms.QuotationForm(request.POST)
book_identifier = request.POST.get('book')
book_id = request.POST.get('book')
if not form.is_valid():
return redirect('/book/%s' % book_identifier)
return redirect('/book/%s' % book_id)
quote = form.cleaned_data.get('quote')
content = form.cleaned_data.get('content')
outgoing.handle_quotation(request.user, book_identifier, content, quote)
return redirect('/book/%s' % book_identifier)
outgoing.handle_quotation(request.user, book_id, content, quote)
return redirect('/book/%s' % book_id)
@login_required
def comment(request):
''' create a book comment '''
form = forms.CommentForm(request.POST)
book_identifier = request.POST.get('book')
book_id = request.POST.get('book')
# TODO: better failure behavior
if not form.is_valid():
return redirect('/book/%s' % book_identifier)
return redirect('/book/%s' % book_id)
# TODO: validation, htmlification
content = form.data.get('content')
outgoing.handle_comment(request.user, book_identifier, content)
return redirect('/book/%s' % book_identifier)
outgoing.handle_comment(request.user, book_id, content)
return redirect('/book/%s' % book_id)
@login_required
@ -265,20 +266,20 @@ def tag(request):
# I'm not using a form here because sometimes "name" is sent as a hidden
# field which doesn't validate
name = request.POST.get('name')
book_identifier = request.POST.get('book')
book_id = request.POST.get('book')
outgoing.handle_tag(request.user, book_identifier, name)
return redirect('/book/%s' % book_identifier)
outgoing.handle_tag(request.user, book_id, name)
return redirect('/book/%s' % book_id)
@login_required
def untag(request):
''' untag a book '''
name = request.POST.get('name')
book_identifier = request.POST.get('book')
book_id = request.POST.get('book')
outgoing.handle_untag(request.user, book_identifier, name)
return redirect('/book/%s' % book_identifier)
outgoing.handle_untag(request.user, book_id, name)
return redirect('/book/%s' % book_id)
@login_required
@ -346,22 +347,6 @@ def unfollow(request):
return redirect('/user/%s' % user_slug)
@login_required
def search(request):
''' that search bar up top '''
query = request.GET.get('q')
if re.match(r'\w+@\w+.\w+', query):
# if something looks like a username, search with webfinger
results = [outgoing.handle_account_search(query)]
template = 'user_results.html'
else:
# just send the question over to book search
results = books_manager.search(query)
template = 'book_results.html'
return TemplateResponse(request, template, {'results': results})
@login_required
def clear_notifications(request):
''' permanently delete notification for user '''

View file

@ -1,15 +1,19 @@
''' views for pages you can go to in the application '''
import re
from django.contrib.auth.decorators import login_required
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
from fedireads import activitypub
from fedireads import activitypub, outgoing
from fedireads import forms, models, books_manager
from fedireads import goodreads_import
from fedireads.books_manager import get_or_create_book
from fedireads.tasks import app
@ -139,6 +143,27 @@ def get_activity_feed(user, filter_level, model=models.Status):
return activities
def search(request):
''' that search bar up top '''
query = request.GET.get('q')
if re.match(r'\w+@\w+.\w+', query):
# if something looks like a username, search with webfinger
results = [outgoing.handle_account_search(query)]
return TemplateResponse(
request, 'user_results.html', {'results': results}
)
# or just send the question over to book search
if is_api_request(request):
# only return local results via json so we don't cause a cascade
results = books_manager.local_search(query)
return JsonResponse([r.__dict__ for r in results], safe=False)
results = books_manager.search(query)
return TemplateResponse(request, 'book_results.html', {'results': results})
def books_page(request):
''' discover books '''
recent_books = models.Work.objects
@ -363,10 +388,9 @@ def edit_profile_page(request):
return TemplateResponse(request, 'edit_user.html', data)
def book_page(request, book_identifier, tab='friends'):
def book_page(request, book_id, tab='friends'):
''' info about a book '''
book = books_manager.get_or_create_book(book_identifier)
book = get_or_create_book(book_id)
if is_api_request(request):
return JsonResponse(activitypub.get_book(book))
@ -430,7 +454,7 @@ def book_page(request, book_identifier, tab='friends'):
{'id': 'federated', 'display': 'Federated'}
],
'active_tab': tab,
'path': '/book/%s' % book_identifier,
'path': '/book/%s' % book_id,
'cover_form': forms.CoverForm(instance=book),
'info_fields': [
{'name': 'ISBN', 'value': book.isbn_13},
@ -445,9 +469,9 @@ def book_page(request, book_identifier, tab='friends'):
@login_required
def edit_book_page(request, book_identifier):
def edit_book_page(request, book_id):
''' info about a book '''
book = books_manager.get_or_create_book(book_identifier)
book = get_or_create_book(book_id)
if not book.description:
book.description = book.parent_work.description
data = {
@ -468,13 +492,16 @@ def editions_page(request, work_id):
return TemplateResponse(request, 'editions.html', data)
def author_page(request, author_identifier):
def author_page(request, author_id):
''' landing page for an author '''
try:
author = models.Author.objects.get(fedireads_key=author_identifier)
author = models.Author.objects.get(id=author_id)
except ValueError:
return HttpResponseNotFound()
if is_api_request(request):
return JsonResponse(activitypub.get_author(author))
books = models.Work.objects.filter(authors=author)
data = {
'author': author,

View file

@ -31,10 +31,10 @@ Connector.objects.create(
books_url='https://%s/book' % DOMAIN,
covers_url='https://%s/images/covers' % DOMAIN,
search_url='https://%s/search?q=' % DOMAIN,
key_name='openlibrary_key',
key_name='id',
priority=1,
)
get_or_create_book('OL1715344W')
get_or_create_book('OL102749W')
get_or_create_book('OL1715344W', key='openlibrary_key', connector_id=1)
get_or_create_book('OL102749W', key='openlibrary_key', connector_id=1)