Merge branch 'main' into suggested_user_logic
This commit is contained in:
commit
6983018d5e
59 changed files with 11724 additions and 3437 deletions
|
@ -7,11 +7,22 @@ from .image import Document
|
|||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Book(ActivityObject):
|
||||
class BookData(ActivityObject):
|
||||
"""shared fields for all book data and authors"""
|
||||
|
||||
openlibraryKey: str = None
|
||||
inventaireId: str = None
|
||||
librarythingKey: str = None
|
||||
goodreadsKey: str = None
|
||||
bnfId: str = None
|
||||
lastEditedBy: str = None
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Book(BookData):
|
||||
"""serializes an edition or work, abstract"""
|
||||
|
||||
title: str
|
||||
lastEditedBy: str = None
|
||||
sortTitle: str = ""
|
||||
subtitle: str = ""
|
||||
description: str = ""
|
||||
|
@ -25,10 +36,6 @@ class Book(ActivityObject):
|
|||
firstPublishedDate: str = ""
|
||||
publishedDate: str = ""
|
||||
|
||||
openlibraryKey: str = ""
|
||||
librarythingKey: str = ""
|
||||
goodreadsKey: str = ""
|
||||
|
||||
cover: Document = None
|
||||
type: str = "Book"
|
||||
|
||||
|
@ -55,23 +62,21 @@ class Work(Book):
|
|||
"""work instance of a book object"""
|
||||
|
||||
lccn: str = ""
|
||||
defaultEdition: str = ""
|
||||
editions: List[str] = field(default_factory=lambda: [])
|
||||
type: str = "Work"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Author(ActivityObject):
|
||||
class Author(BookData):
|
||||
"""author of a book"""
|
||||
|
||||
name: str
|
||||
lastEditedBy: str = None
|
||||
isni: str = None
|
||||
viafId: str = None
|
||||
gutenbergId: str = None
|
||||
born: str = None
|
||||
died: str = None
|
||||
aliases: List[str] = field(default_factory=lambda: [])
|
||||
bio: str = ""
|
||||
openlibraryKey: str = ""
|
||||
librarythingKey: str = ""
|
||||
goodreadsKey: str = ""
|
||||
wikipediaLink: str = ""
|
||||
type: str = "Author"
|
||||
|
|
|
@ -44,7 +44,7 @@ class AbstractMinimalConnector(ABC):
|
|||
if min_confidence:
|
||||
params["min_confidence"] = min_confidence
|
||||
|
||||
data = get_data(
|
||||
data = self.get_search_data(
|
||||
"%s%s" % (self.search_url, query),
|
||||
params=params,
|
||||
)
|
||||
|
@ -57,7 +57,7 @@ class AbstractMinimalConnector(ABC):
|
|||
def isbn_search(self, query):
|
||||
"""isbn search"""
|
||||
params = {}
|
||||
data = get_data(
|
||||
data = self.get_search_data(
|
||||
"%s%s" % (self.isbn_search_url, query),
|
||||
params=params,
|
||||
)
|
||||
|
@ -68,6 +68,10 @@ class AbstractMinimalConnector(ABC):
|
|||
results.append(self.format_isbn_search_result(doc))
|
||||
return results
|
||||
|
||||
def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use
|
||||
"""this allows connectors to override the default behavior"""
|
||||
return get_data(remote_id, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
def get_or_create_book(self, remote_id):
|
||||
"""pull up a book record by whatever means possible"""
|
||||
|
@ -112,12 +116,12 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
remote_id
|
||||
) or models.Work.find_existing_by_remote_id(remote_id)
|
||||
if existing:
|
||||
if hasattr(existing, "get_default_editon"):
|
||||
return existing.get_default_editon()
|
||||
if hasattr(existing, "default_edition"):
|
||||
return existing.default_edition
|
||||
return existing
|
||||
|
||||
# load the json
|
||||
data = get_data(remote_id)
|
||||
data = self.get_book_data(remote_id)
|
||||
mapped_data = dict_from_mappings(data, self.book_mappings)
|
||||
if self.is_work_data(data):
|
||||
try:
|
||||
|
@ -128,12 +132,12 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
edition_data = data
|
||||
work_data = mapped_data
|
||||
else:
|
||||
edition_data = data
|
||||
try:
|
||||
work_data = self.get_work_from_edition_data(data)
|
||||
work_data = dict_from_mappings(work_data, self.book_mappings)
|
||||
except (KeyError, ConnectorException):
|
||||
work_data = mapped_data
|
||||
edition_data = data
|
||||
|
||||
if not work_data or not edition_data:
|
||||
raise ConnectorException("Unable to load book data: %s" % remote_id)
|
||||
|
@ -150,6 +154,10 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
load_more_data.delay(self.connector.id, work.id)
|
||||
return edition
|
||||
|
||||
def get_book_data(self, remote_id): # pylint: disable=no-self-use
|
||||
"""this allows connectors to override the default behavior"""
|
||||
return get_data(remote_id)
|
||||
|
||||
def create_edition_from_data(self, work, edition_data):
|
||||
"""if we already have the work, we're ready"""
|
||||
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
|
||||
|
@ -159,10 +167,6 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
edition.connector = self.connector
|
||||
edition.save()
|
||||
|
||||
if not work.default_edition:
|
||||
work.default_edition = edition
|
||||
work.save()
|
||||
|
||||
for author in self.get_authors_from_data(edition_data):
|
||||
edition.authors.add(author)
|
||||
if not edition.authors.exists() and work.authors.exists():
|
||||
|
@ -176,7 +180,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
if existing:
|
||||
return existing
|
||||
|
||||
data = get_data(remote_id)
|
||||
data = self.get_book_data(remote_id)
|
||||
|
||||
mapped_data = dict_from_mappings(data, self.author_mappings)
|
||||
try:
|
||||
|
@ -273,6 +277,7 @@ class SearchResult:
|
|||
title: str
|
||||
key: str
|
||||
connector: object
|
||||
view_link: str = None
|
||||
author: str = None
|
||||
year: str = None
|
||||
cover: str = None
|
||||
|
|
|
@ -7,11 +7,7 @@ class Connector(AbstractMinimalConnector):
|
|||
"""this is basically just for search"""
|
||||
|
||||
def get_or_create_book(self, remote_id):
|
||||
edition = activitypub.resolve_remote_id(remote_id, model=models.Edition)
|
||||
work = edition.parent_work
|
||||
work.default_edition = work.get_default_edition()
|
||||
work.save()
|
||||
return edition
|
||||
return activitypub.resolve_remote_id(remote_id, model=models.Edition)
|
||||
|
||||
def parse_search_data(self, data):
|
||||
return data
|
||||
|
|
|
@ -67,10 +67,12 @@ def search(query, min_confidence=0.1):
|
|||
return results
|
||||
|
||||
|
||||
def local_search(query, min_confidence=0.1, raw=False):
|
||||
def local_search(query, min_confidence=0.1, raw=False, filters=None):
|
||||
"""only look at local search results"""
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.search(query, min_confidence=min_confidence, raw=raw)
|
||||
return connector.search(
|
||||
query, min_confidence=min_confidence, raw=raw, filters=filters
|
||||
)
|
||||
|
||||
|
||||
def isbn_local_search(query, raw=False):
|
||||
|
|
214
bookwyrm/connectors/inventaire.py
Normal file
214
bookwyrm/connectors/inventaire.py
Normal file
|
@ -0,0 +1,214 @@
|
|||
""" inventaire data connector """
|
||||
import re
|
||||
|
||||
from bookwyrm import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult, Mapping
|
||||
from .abstract_connector import get_data
|
||||
from .connector_manager import ConnectorException
|
||||
|
||||
|
||||
class Connector(AbstractConnector):
|
||||
"""instantiate a connector for OL"""
|
||||
|
||||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
||||
get_first = lambda a: a[0]
|
||||
shared_mappings = [
|
||||
Mapping("id", remote_field="uri", formatter=self.get_remote_id),
|
||||
Mapping("bnfId", remote_field="wdt:P268", formatter=get_first),
|
||||
Mapping("openlibraryKey", remote_field="wdt:P648", formatter=get_first),
|
||||
]
|
||||
self.book_mappings = [
|
||||
Mapping("title", remote_field="wdt:P1476", formatter=get_first),
|
||||
Mapping("subtitle", remote_field="wdt:P1680", formatter=get_first),
|
||||
Mapping("inventaireId", remote_field="uri"),
|
||||
Mapping(
|
||||
"description", remote_field="sitelinks", formatter=self.get_description
|
||||
),
|
||||
Mapping("cover", remote_field="image", formatter=self.get_cover_url),
|
||||
Mapping("isbn13", remote_field="wdt:P212", formatter=get_first),
|
||||
Mapping("isbn10", remote_field="wdt:P957", formatter=get_first),
|
||||
Mapping("oclcNumber", remote_field="wdt:P5331", formatter=get_first),
|
||||
Mapping("goodreadsKey", remote_field="wdt:P2969", formatter=get_first),
|
||||
Mapping("librarythingKey", remote_field="wdt:P1085", formatter=get_first),
|
||||
Mapping("languages", remote_field="wdt:P407", formatter=self.resolve_keys),
|
||||
Mapping("publishers", remote_field="wdt:P123", formatter=self.resolve_keys),
|
||||
Mapping("publishedDate", remote_field="wdt:P577", formatter=get_first),
|
||||
Mapping("pages", remote_field="wdt:P1104", formatter=get_first),
|
||||
Mapping(
|
||||
"subjectPlaces", remote_field="wdt:P840", formatter=self.resolve_keys
|
||||
),
|
||||
Mapping("subjects", remote_field="wdt:P921", formatter=self.resolve_keys),
|
||||
Mapping("asin", remote_field="wdt:P5749", formatter=get_first),
|
||||
] + shared_mappings
|
||||
# TODO: P136: genre, P674 characters, P950 bne
|
||||
|
||||
self.author_mappings = [
|
||||
Mapping("id", remote_field="uri", formatter=self.get_remote_id),
|
||||
Mapping("name", remote_field="labels", formatter=get_language_code),
|
||||
Mapping("bio", remote_field="sitelinks", formatter=self.get_description),
|
||||
Mapping("goodreadsKey", remote_field="wdt:P2963", formatter=get_first),
|
||||
Mapping("isni", remote_field="wdt:P213", formatter=get_first),
|
||||
Mapping("viafId", remote_field="wdt:P214", formatter=get_first),
|
||||
Mapping("gutenberg_id", remote_field="wdt:P1938", formatter=get_first),
|
||||
Mapping("born", remote_field="wdt:P569", formatter=get_first),
|
||||
Mapping("died", remote_field="wdt:P570", formatter=get_first),
|
||||
] + shared_mappings
|
||||
|
||||
def get_remote_id(self, value):
|
||||
"""convert an id/uri into a url"""
|
||||
return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value)
|
||||
|
||||
def get_book_data(self, remote_id):
|
||||
data = get_data(remote_id)
|
||||
extracted = list(data.get("entities").values())
|
||||
try:
|
||||
data = extracted[0]
|
||||
except KeyError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
# flatten the data so that images, uri, and claims are on the same level
|
||||
return {
|
||||
**data.get("claims", {}),
|
||||
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]},
|
||||
}
|
||||
|
||||
def parse_search_data(self, data):
|
||||
return data.get("results")
|
||||
|
||||
def format_search_result(self, search_result):
|
||||
images = search_result.get("image")
|
||||
cover = (
|
||||
"{:s}/img/entities/{:s}".format(self.covers_url, images[0])
|
||||
if images
|
||||
else None
|
||||
)
|
||||
return SearchResult(
|
||||
title=search_result.get("label"),
|
||||
key=self.get_remote_id(search_result.get("uri")),
|
||||
author=search_result.get("description"),
|
||||
view_link="{:s}/entity/{:s}".format(
|
||||
self.base_url, search_result.get("uri")
|
||||
),
|
||||
cover=cover,
|
||||
connector=self,
|
||||
)
|
||||
|
||||
def parse_isbn_search_data(self, data):
|
||||
"""got some daaaata"""
|
||||
results = data.get("entities")
|
||||
if not results:
|
||||
return []
|
||||
return list(results.values())
|
||||
|
||||
def format_isbn_search_result(self, search_result):
|
||||
"""totally different format than a regular search result"""
|
||||
title = search_result.get("claims", {}).get("wdt:P1476", [])
|
||||
if not title:
|
||||
return None
|
||||
return SearchResult(
|
||||
title=title[0],
|
||||
key=self.get_remote_id(search_result.get("uri")),
|
||||
author=search_result.get("description"),
|
||||
view_link="{:s}/entity/{:s}".format(
|
||||
self.base_url, search_result.get("uri")
|
||||
),
|
||||
cover=self.get_cover_url(search_result.get("image")),
|
||||
connector=self,
|
||||
)
|
||||
|
||||
def is_work_data(self, data):
|
||||
return data.get("type") == "work"
|
||||
|
||||
def load_edition_data(self, work_uri):
|
||||
"""get a list of editions for a work"""
|
||||
url = "{:s}?action=reverse-claims&property=wdt:P629&value={:s}".format(
|
||||
self.books_url, work_uri
|
||||
)
|
||||
return get_data(url)
|
||||
|
||||
def get_edition_from_work_data(self, data):
|
||||
data = self.load_edition_data(data.get("uri"))
|
||||
try:
|
||||
uri = data["uris"][0]
|
||||
except KeyError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
return self.get_book_data(self.get_remote_id(uri))
|
||||
|
||||
def get_work_from_edition_data(self, data):
|
||||
try:
|
||||
uri = data["claims"]["wdt:P629"]
|
||||
except KeyError:
|
||||
raise ConnectorException("Invalid book data")
|
||||
return self.get_book_data(self.get_remote_id(uri))
|
||||
|
||||
def get_authors_from_data(self, data):
|
||||
authors = data.get("wdt:P50", [])
|
||||
for author in authors:
|
||||
yield self.get_or_create_author(self.get_remote_id(author))
|
||||
|
||||
def expand_book_data(self, book):
|
||||
work = book
|
||||
# go from the edition to the work, if necessary
|
||||
if isinstance(book, models.Edition):
|
||||
work = book.parent_work
|
||||
|
||||
try:
|
||||
edition_options = self.load_edition_data(work.inventaire_id)
|
||||
except ConnectorException:
|
||||
# who knows, man
|
||||
return
|
||||
|
||||
for edition_uri in edition_options.get("uris"):
|
||||
remote_id = self.get_remote_id(edition_uri)
|
||||
try:
|
||||
data = self.get_book_data(remote_id)
|
||||
except ConnectorException:
|
||||
# who, indeed, knows
|
||||
continue
|
||||
self.create_edition_from_data(work, data)
|
||||
|
||||
def get_cover_url(self, cover_blob, *_):
|
||||
"""format the relative cover url into an absolute one:
|
||||
{"url": "/img/entities/e794783f01b9d4f897a1ea9820b96e00d346994f"}
|
||||
"""
|
||||
# covers may or may not be a list
|
||||
if isinstance(cover_blob, list) and len(cover_blob) > 0:
|
||||
cover_blob = cover_blob[0]
|
||||
cover_id = cover_blob.get("url")
|
||||
if not cover_id:
|
||||
return None
|
||||
# cover may or may not be an absolute url already
|
||||
if re.match(r"^http", cover_id):
|
||||
return cover_id
|
||||
return "%s%s" % (self.covers_url, cover_id)
|
||||
|
||||
def resolve_keys(self, keys):
|
||||
"""cool, it's "wd:Q3156592" now what the heck does that mean"""
|
||||
results = []
|
||||
for uri in keys:
|
||||
try:
|
||||
data = self.get_book_data(self.get_remote_id(uri))
|
||||
except ConnectorException:
|
||||
continue
|
||||
results.append(get_language_code(data.get("labels")))
|
||||
return results
|
||||
|
||||
def get_description(self, links):
|
||||
"""grab an extracted excerpt from wikipedia"""
|
||||
link = links.get("enwiki")
|
||||
if not link:
|
||||
return ""
|
||||
url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format(
|
||||
self.base_url, link
|
||||
)
|
||||
try:
|
||||
data = get_data(url)
|
||||
except ConnectorException:
|
||||
return ""
|
||||
return data.get("extract")
|
||||
|
||||
|
||||
def get_language_code(options, code="en"):
|
||||
"""when there are a bunch of translation but we need a single field"""
|
||||
return options.get(code)
|
|
@ -14,8 +14,8 @@ class Connector(AbstractConnector):
|
|||
def __init__(self, identifier):
|
||||
super().__init__(identifier)
|
||||
|
||||
get_first = lambda a: a[0]
|
||||
get_remote_id = lambda a: self.base_url + a
|
||||
get_first = lambda a, *args: a[0]
|
||||
get_remote_id = lambda a, *args: self.base_url + a
|
||||
self.book_mappings = [
|
||||
Mapping("title"),
|
||||
Mapping("id", remote_field="key", formatter=get_remote_id),
|
||||
|
|
|
@ -3,7 +3,7 @@ from functools import reduce
|
|||
import operator
|
||||
|
||||
from django.contrib.postgres.search import SearchRank, SearchVector
|
||||
from django.db.models import Count, F, Q
|
||||
from django.db.models import Count, OuterRef, Subquery, F, Q
|
||||
|
||||
from bookwyrm import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult
|
||||
|
@ -13,15 +13,16 @@ class Connector(AbstractConnector):
|
|||
"""instantiate a connector"""
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def search(self, query, min_confidence=0.1, raw=False):
|
||||
def search(self, query, min_confidence=0.1, raw=False, filters=None):
|
||||
"""search your local database"""
|
||||
filters = filters or []
|
||||
if not query:
|
||||
return []
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query)
|
||||
results = search_identifiers(query, *filters)
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(query, min_confidence)
|
||||
results = search_title_author(query, min_confidence, *filters)
|
||||
search_results = []
|
||||
for result in results:
|
||||
if raw:
|
||||
|
@ -46,7 +47,16 @@ class Connector(AbstractConnector):
|
|||
|
||||
# when there are multiple editions of the same work, pick the default.
|
||||
# it would be odd for this to happen.
|
||||
results = results.filter(parent_work__default_edition__id=F("id")) or results
|
||||
|
||||
default_editions = models.Edition.objects.filter(
|
||||
parent_work=OuterRef("parent_work")
|
||||
).order_by("-edition_rank")
|
||||
results = (
|
||||
results.annotate(
|
||||
default_id=Subquery(default_editions.values("id")[:1])
|
||||
).filter(default_id=F("id"))
|
||||
or results
|
||||
)
|
||||
|
||||
search_results = []
|
||||
for result in results:
|
||||
|
@ -59,6 +69,10 @@ class Connector(AbstractConnector):
|
|||
return search_results
|
||||
|
||||
def format_search_result(self, search_result):
|
||||
cover = None
|
||||
if search_result.cover:
|
||||
cover = "%s%s" % (self.covers_url, search_result.cover)
|
||||
|
||||
return SearchResult(
|
||||
title=search_result.title,
|
||||
key=search_result.remote_id,
|
||||
|
@ -67,7 +81,7 @@ class Connector(AbstractConnector):
|
|||
if search_result.published_date
|
||||
else None,
|
||||
connector=self,
|
||||
cover="%s%s" % (self.covers_url, search_result.cover),
|
||||
cover=cover,
|
||||
confidence=search_result.rank if hasattr(search_result, "rank") else 1,
|
||||
)
|
||||
|
||||
|
@ -98,23 +112,31 @@ class Connector(AbstractConnector):
|
|||
pass
|
||||
|
||||
|
||||
def search_identifiers(query):
|
||||
def search_identifiers(query, *filters):
|
||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||
filters = [
|
||||
or_filters = [
|
||||
{f.name: query}
|
||||
for f in models.Edition._meta.get_fields()
|
||||
if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||
]
|
||||
results = models.Edition.objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters))
|
||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||
).distinct()
|
||||
|
||||
# when there are multiple editions of the same work, pick the default.
|
||||
# it would be odd for this to happen.
|
||||
return results.filter(parent_work__default_edition__id=F("id")) or results
|
||||
default_editions = models.Edition.objects.filter(
|
||||
parent_work=OuterRef("parent_work")
|
||||
).order_by("-edition_rank")
|
||||
return (
|
||||
results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter(
|
||||
default_id=F("id")
|
||||
)
|
||||
or results
|
||||
)
|
||||
|
||||
|
||||
def search_title_author(query, min_confidence):
|
||||
def search_title_author(query, min_confidence, *filters):
|
||||
"""searches for title and author"""
|
||||
vector = (
|
||||
SearchVector("title", weight="A")
|
||||
|
@ -126,7 +148,7 @@ def search_title_author(query, min_confidence):
|
|||
results = (
|
||||
models.Edition.objects.annotate(search=vector)
|
||||
.annotate(rank=SearchRank(vector, query))
|
||||
.filter(rank__gt=min_confidence)
|
||||
.filter(*filters, rank__gt=min_confidence)
|
||||
.order_by("-rank")
|
||||
)
|
||||
|
||||
|
@ -139,10 +161,10 @@ def search_title_author(query, min_confidence):
|
|||
|
||||
for work_id in set(editions_of_work):
|
||||
editions = results.filter(parent_work=work_id)
|
||||
default = editions.filter(parent_work__default_edition=F("id"))
|
||||
default_rank = default.first().rank if default.exists() else 0
|
||||
default = editions.order_by("-edition_rank").first()
|
||||
default_rank = default.rank if default else 0
|
||||
# if mutliple books have the top rank, pick the default edition
|
||||
if default_rank == editions.first().rank:
|
||||
yield default.first()
|
||||
yield default
|
||||
else:
|
||||
yield editions.first()
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
""" settings book data connectors """
|
||||
|
||||
CONNECTORS = ["openlibrary", "self_connector", "bookwyrm_connector"]
|
||||
CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"]
|
||||
|
|
|
@ -116,24 +116,33 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
read.save()
|
||||
|
||||
if include_reviews and (item.rating or item.review):
|
||||
review_title = (
|
||||
"Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
source,
|
||||
)
|
||||
if item.review
|
||||
else ""
|
||||
)
|
||||
|
||||
# we don't know the publication date of the review,
|
||||
# but "now" is a bad guess
|
||||
published_date_guess = item.date_read or item.date_added
|
||||
models.Review.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
if item.review:
|
||||
review_title = (
|
||||
"Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
source,
|
||||
)
|
||||
if item.review
|
||||
else ""
|
||||
)
|
||||
models.Review.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
name=review_title,
|
||||
content=item.review,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
else:
|
||||
# just a rating
|
||||
models.ReviewRating.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
|
|
|
@ -94,6 +94,18 @@ def init_connectors():
|
|||
priority=2,
|
||||
)
|
||||
|
||||
Connector.objects.create(
|
||||
identifier="inventaire.io",
|
||||
name="Inventaire",
|
||||
connector_file="inventaire",
|
||||
base_url="https://inventaire.io",
|
||||
books_url="https://inventaire.io/api/entities",
|
||||
covers_url="https://inventaire.io",
|
||||
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
|
||||
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
|
||||
priority=3,
|
||||
)
|
||||
|
||||
Connector.objects.create(
|
||||
identifier="openlibrary.org",
|
||||
name="OpenLibrary",
|
||||
|
|
30
bookwyrm/migrations/0062_auto_20210406_1731.py
Normal file
30
bookwyrm/migrations/0062_auto_20210406_1731.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 3.1.6 on 2021-04-06 17:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0061_auto_20210402_1435"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="connector",
|
||||
name="connector_file_valid",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="connector",
|
||||
name="connector_file",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("openlibrary", "Openlibrary"),
|
||||
("inventaire", "Inventaire"),
|
||||
("self_connector", "Self Connector"),
|
||||
("bookwyrm_connector", "Bookwyrm Connector"),
|
||||
],
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
63
bookwyrm/migrations/0063_auto_20210407_0045.py
Normal file
63
bookwyrm/migrations/0063_auto_20210407_0045.py
Normal file
|
@ -0,0 +1,63 @@
|
|||
# Generated by Django 3.1.6 on 2021-04-07 00:45
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0062_auto_20210406_1731"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="bnf_id",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="gutenberg_id",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="inventaire_id",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="isni",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="author",
|
||||
name="viaf_id",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="bnf_id",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="inventaire_id",
|
||||
field=bookwyrm.models.fields.CharField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.2 on 2021-04-26 21:32
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0063_auto_20210407_0045"),
|
||||
("bookwyrm", "0070_auto_20210423_0121"),
|
||||
]
|
||||
|
||||
operations = []
|
17
bookwyrm/migrations/0072_remove_work_default_edition.py
Normal file
17
bookwyrm/migrations/0072_remove_work_default_edition.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2 on 2021-04-28 22:16
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="work",
|
||||
name="default_edition",
|
||||
),
|
||||
]
|
|
@ -14,6 +14,15 @@ class Author(BookDataModel):
|
|||
wikipedia_link = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
isni = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
viaf_id = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
gutenberg_id = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
# idk probably other keys would be useful here?
|
||||
born = fields.DateTimeField(blank=True, null=True)
|
||||
died = fields.DateTimeField(blank=True, null=True)
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
""" database schema for books and shelves """
|
||||
import re
|
||||
|
||||
from django.db import models, transaction
|
||||
from django.db import models
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE
|
||||
|
||||
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
|
||||
from .base_model import BookWyrmModel
|
||||
|
@ -19,12 +19,18 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
openlibrary_key = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
inventaire_id = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
librarything_key = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
goodreads_key = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
bnf_id = fields.CharField( # Bibliothèque nationale de France
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
|
||||
last_edited_by = fields.ForeignKey(
|
||||
"User",
|
||||
|
@ -137,10 +143,6 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
lccn = fields.CharField(
|
||||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
# this has to be nullable but should never be null
|
||||
default_edition = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, null=True, load_remote=False
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""set some fields on the edition object"""
|
||||
|
@ -149,18 +151,10 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
edition.save()
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_default_edition(self):
|
||||
@property
|
||||
def default_edition(self):
|
||||
"""in case the default edition is not set"""
|
||||
return self.default_edition or self.editions.order_by("-edition_rank").first()
|
||||
|
||||
@transaction.atomic()
|
||||
def reset_default_edition(self):
|
||||
"""sets a new default edition based on computed rank"""
|
||||
self.default_edition = None
|
||||
# editions are re-ranked implicitly
|
||||
self.save()
|
||||
self.default_edition = self.get_default_edition()
|
||||
self.save()
|
||||
return self.editions.order_by("-edition_rank").first()
|
||||
|
||||
def to_edition_list(self, **kwargs):
|
||||
"""an ordered collection of editions"""
|
||||
|
@ -214,17 +208,20 @@ class Edition(Book):
|
|||
activity_serializer = activitypub.Edition
|
||||
name_field = "title"
|
||||
|
||||
def get_rank(self, ignore_default=False):
|
||||
def get_rank(self):
|
||||
"""calculate how complete the data is on this edition"""
|
||||
if (
|
||||
not ignore_default
|
||||
and self.parent_work
|
||||
and self.parent_work.default_edition == self
|
||||
):
|
||||
# default edition has the highest rank
|
||||
return 20
|
||||
rank = 0
|
||||
# big ups for havinga cover
|
||||
rank += int(bool(self.cover)) * 3
|
||||
# is it in the instance's preferred language?
|
||||
rank += int(bool(DEFAULT_LANGUAGE in self.languages))
|
||||
# prefer print editions
|
||||
if self.physical_format:
|
||||
rank += int(
|
||||
bool(self.physical_format.lower() in ["paperback", "hardcover"])
|
||||
)
|
||||
|
||||
# does it have metadata?
|
||||
rank += int(bool(self.isbn_13))
|
||||
rank += int(bool(self.isbn_10))
|
||||
rank += int(bool(self.oclc_number))
|
||||
|
@ -242,6 +239,12 @@ class Edition(Book):
|
|||
if self.isbn_10 and not self.isbn_13:
|
||||
self.isbn_13 = isbn_10_to_13(self.isbn_10)
|
||||
|
||||
# normalize isbn format
|
||||
if self.isbn_10:
|
||||
self.isbn_10 = re.sub(r"[^0-9X]", "", self.isbn_10)
|
||||
if self.isbn_13:
|
||||
self.isbn_13 = re.sub(r"[^0-9X]", "", self.isbn_13)
|
||||
|
||||
# set rank
|
||||
self.edition_rank = self.get_rank()
|
||||
|
||||
|
|
|
@ -31,16 +31,6 @@ class Connector(BookWyrmModel):
|
|||
# when to reset the query count back to 0 (ie, after 1 day)
|
||||
query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
"""check that there's code to actually use this connector"""
|
||||
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(connector_file__in=ConnectorFiles),
|
||||
name="connector_file_valid",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return "{} ({})".format(
|
||||
self.identifier,
|
||||
|
|
|
@ -11,6 +11,7 @@ DOMAIN = env("DOMAIN")
|
|||
VERSION = "0.0.1"
|
||||
|
||||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||
|
||||
# celery
|
||||
CELERY_BROKER = env("CELERY_BROKER")
|
||||
|
@ -34,6 +35,8 @@ LOCALE_PATHS = [
|
|||
os.path.join(BASE_DIR, "locale"),
|
||||
]
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
|
||||
|
||||
|
|
|
@ -81,6 +81,9 @@
|
|||
{% if book.openlibrary_key %}
|
||||
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
|
||||
{% endif %}
|
||||
{% if book.inventaire_id %}
|
||||
<p><a href="https://inventaire.io/entity/{{ book.inventaire_id }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -13,6 +13,16 @@
|
|||
|
||||
<div class="columns mt-3">
|
||||
<section class="column is-three-quarters">
|
||||
{% if request.GET.updated %}
|
||||
<div class="notification is-primary">
|
||||
{% if list.curation != "open" and request.user != list.user %}
|
||||
{% trans "You successfully suggested a book for this list!" %}
|
||||
{% else %}
|
||||
{% trans "You successfully added a book to this list!" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not items.object_list.exists %}
|
||||
<p>{% trans "This list is currently empty" %}</p>
|
||||
{% else %}
|
||||
|
@ -116,7 +126,7 @@
|
|||
</div>
|
||||
<div class="column">
|
||||
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
||||
<form name="add-book" method="post" action="{% url 'list-add-book' %}">
|
||||
<form name="add-book" method="post" action="{% url 'list-add-book' %}{% if query %}?q={{ query }}{% endif %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ book.id }}">
|
||||
<input type="hidden" name="list" value="{{ list.id }}">
|
||||
|
|
|
@ -11,10 +11,15 @@
|
|||
|
||||
<div class="block columns">
|
||||
<div class="column">
|
||||
<h2 class="title">{% trans "Matching Books" %}</h2>
|
||||
<h2 class="title is-4">{% trans "Matching Books" %}</h2>
|
||||
<section class="block">
|
||||
{% if not local_results.results %}
|
||||
<p>{% blocktrans %}No books found for "{{ query }}"{% endblocktrans %}</p>
|
||||
<p><em>{% blocktrans %}No books found for "{{ query }}"{% endblocktrans %}</em></p>
|
||||
{% if not user.is_authenticated %}
|
||||
<p>
|
||||
<a href="{% url 'login' %}">{% trans "Log in to import or add books." %}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for result in local_results.results %}
|
||||
|
@ -29,39 +34,56 @@
|
|||
{% if request.user.is_authenticated %}
|
||||
{% if book_results|slice:":1" and local_results.results %}
|
||||
<div class="block">
|
||||
<p>
|
||||
<h3 class="title is-6 mb-0">
|
||||
{% trans "Didn't find what you were looking for?" %}
|
||||
</p>
|
||||
</h3>
|
||||
{% trans "Show results from other catalogues" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text small=True controls_text="more-results" %}
|
||||
|
||||
{% if local_results.results %}
|
||||
{% trans "Hide results from other catalogues" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="{% if local_results.results %}is-hidden{% endif %}" id="more-results">
|
||||
{% for result_set in book_results|slice:"1:" %}
|
||||
{% if result_set.results %}
|
||||
<section class="block">
|
||||
<section class="box has-background-white-bis">
|
||||
{% if not result_set.connector.local %}
|
||||
<h3 class="title is-5">
|
||||
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>
|
||||
<header class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h3 class="title is-5">
|
||||
Results from
|
||||
<a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% trans "Show" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text small=True controls_text="more-results-panel" controls_uid=result_set.connector.identifier class="is-small" icon="arrow-down" pressed=forloop.first %}
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<ul>
|
||||
{% for result in result_set.results %}
|
||||
<li class="pb-4">
|
||||
{% include 'snippets/search_result_text.html' with result=result remote_result=True %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="box has-background-white is-shadowless{% if not forloop.first %} is-hidden{% endif %}" id="more-results-panel-{{ result_set.connector.identifier }}">
|
||||
<div class="is-flex is-flex-direction-row-reverse">
|
||||
<div>
|
||||
{% trans "Close" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=button_text class="delete" nonbutton=True controls_text="more-results-panel" controls_uid=result_set.connector.identifier pressed=forloop.first %}
|
||||
</div>
|
||||
<ul class="is-flex-grow-1">
|
||||
{% for result in result_set.results %}
|
||||
<li class="pb-4">
|
||||
{% include 'snippets/search_result_text.html' with result=result remote_result=True %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if local_results.results %}
|
||||
{% trans "Hide results from other catalogues" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text small=True controls_text="more-results" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
|
@ -70,10 +92,11 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
<div class="column">
|
||||
<section class="block">
|
||||
<h2 class="title">{% trans "Matching Users" %}</h2>
|
||||
{% if request.user.is_authenticated %}
|
||||
<section class="box">
|
||||
<h2 class="title is-4">{% trans "Matching Users" %}</h2>
|
||||
{% if not user_results %}
|
||||
<p>{% blocktrans %}No users found for "{{ query }}"{% endblocktrans %}</p>
|
||||
<p><em>{% blocktrans %}No users found for "{{ query }}"{% endblocktrans %}</em></p>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for result in user_results %}
|
||||
|
@ -87,10 +110,11 @@
|
|||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="block">
|
||||
<h2 class="title">{% trans "Lists" %}</h2>
|
||||
{% endif %}
|
||||
<section class="box">
|
||||
<h2 class="title is-4">{% trans "Lists" %}</h2>
|
||||
{% if not list_results %}
|
||||
<p>{% blocktrans %}No lists found for "{{ query }}"{% endblocktrans %}</p>
|
||||
<p><em>{% blocktrans %}No lists found for "{{ query }}"{% endblocktrans %}</em></p>
|
||||
{% endif %}
|
||||
{% for result in list_results %}
|
||||
<div class="block">
|
||||
|
|
|
@ -16,10 +16,15 @@
|
|||
<div class="column">
|
||||
<p>
|
||||
<strong>
|
||||
<a href="{{ result.key }}"{% if remote_result %} rel=”noopener” target="_blank"{% endif %}>{{ result.title }}</a>
|
||||
<a href="{{ result.view_link|default:result.key }}"{% if remote_result %} rel=”noopener” target="_blank"{% endif %}>{{ result.title }}</a>
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
{% if result.author %}
|
||||
{% blocktrans with author=result.author %}by {{ author }}{% endblocktrans %}{% endif %}{% if result.year %} ({{ result.year }})
|
||||
{{ result.author }}
|
||||
{% endif %}
|
||||
{% if result.year %}
|
||||
({{ result.year }})
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<figure class="media-left" aria-hidden="true">
|
||||
<a class="image is-48x48" href="{{ status.user.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" medium="true" %}
|
||||
</a>
|
||||
</a>
|
||||
</figure>
|
||||
|
||||
<div class="media-content">
|
||||
|
@ -47,7 +47,7 @@
|
|||
|
||||
{% if status.book %}
|
||||
{% if status.status_type == 'GeneratedNote' or status.status_type == 'Rating' %}
|
||||
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}:
|
||||
<a href="/book/{{ status.book.id }}">{{ status.book|title }}</a>{% if status.status_type == 'Rating' %}: {% include 'snippets/stars.html' with rating=status.rating %}
|
||||
<span
|
||||
itemprop="reviewRating"
|
||||
itemscope
|
||||
|
@ -71,7 +71,6 @@
|
|||
<meta itemprop="bestRating" content="5">
|
||||
</span>
|
||||
|
||||
{% include 'snippets/stars.html' with rating=status.rating %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% include 'snippets/book_titleby.html' with book=status.book %}
|
||||
|
|
|
@ -29,4 +29,6 @@
|
|||
<div>{% blocktrans with username=user.display_name %}{{ username }} has no followers{% endblocktrans %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=followers path=request.path %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -29,4 +29,6 @@
|
|||
<div>{% blocktrans with username=user|username %}{{ username }} isn't following any users{% endblocktrans %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% include 'snippets/pagination.html' with page=following path=request.path %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -17,8 +17,6 @@ class ConnectorManager(TestCase):
|
|||
self.edition = models.Edition.objects.create(
|
||||
title="Example Edition", parent_work=self.work, isbn_10="0000000000"
|
||||
)
|
||||
self.work.default_edition = self.edition
|
||||
self.work.save()
|
||||
|
||||
self.connector = models.Connector.objects.create(
|
||||
identifier="test_connector",
|
||||
|
|
158
bookwyrm/tests/connectors/test_inventaire_connector.py
Normal file
158
bookwyrm/tests/connectors/test_inventaire_connector.py
Normal file
|
@ -0,0 +1,158 @@
|
|||
""" testing book data connectors """
|
||||
import json
|
||||
import pathlib
|
||||
from django.test import TestCase
|
||||
import responses
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.connectors.inventaire import Connector
|
||||
|
||||
|
||||
class Inventaire(TestCase):
|
||||
"""test loading data from inventaire.io"""
|
||||
|
||||
def setUp(self):
|
||||
"""creates the connector we'll use"""
|
||||
models.Connector.objects.create(
|
||||
identifier="inventaire.io",
|
||||
name="Inventaire",
|
||||
connector_file="inventaire",
|
||||
base_url="https://inventaire.io",
|
||||
books_url="https://inventaire.io",
|
||||
covers_url="https://covers.inventaire.io",
|
||||
search_url="https://inventaire.io/search?q=",
|
||||
isbn_search_url="https://inventaire.io/isbn",
|
||||
)
|
||||
self.connector = Connector("inventaire.io")
|
||||
|
||||
@responses.activate
|
||||
def test_get_book_data(self):
|
||||
"""flattens the default structure to make it easier to parse"""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://test.url/ok",
|
||||
json={
|
||||
"entities": {
|
||||
"isbn:9780375757853": {
|
||||
"claims": {
|
||||
"wdt:P31": ["wd:Q3331189"],
|
||||
},
|
||||
"uri": "isbn:9780375757853",
|
||||
}
|
||||
},
|
||||
"redirects": {},
|
||||
},
|
||||
)
|
||||
|
||||
result = self.connector.get_book_data("https://test.url/ok")
|
||||
self.assertEqual(result["wdt:P31"], ["wd:Q3331189"])
|
||||
self.assertEqual(result["uri"], "isbn:9780375757853")
|
||||
|
||||
def test_format_search_result(self):
|
||||
"""json to search result objs"""
|
||||
search_file = pathlib.Path(__file__).parent.joinpath(
|
||||
"../data/inventaire_search.json"
|
||||
)
|
||||
search_results = json.loads(search_file.read_bytes())
|
||||
|
||||
results = self.connector.parse_search_data(search_results)
|
||||
formatted = self.connector.format_search_result(results[0])
|
||||
|
||||
self.assertEqual(formatted.title, "The Stories of Vladimir Nabokov")
|
||||
self.assertEqual(
|
||||
formatted.key, "https://inventaire.io?action=by-uris&uris=wd:Q7766679"
|
||||
)
|
||||
self.assertEqual(
|
||||
formatted.cover,
|
||||
"https://covers.inventaire.io/img/entities/ddb32e115a28dcc0465023869ba19f6868ec4042",
|
||||
)
|
||||
|
||||
def test_get_cover_url(self):
|
||||
"""figure out where the cover image is"""
|
||||
cover_blob = {"url": "/img/entities/d46a8"}
|
||||
result = self.connector.get_cover_url(cover_blob)
|
||||
self.assertEqual(result, "https://covers.inventaire.io/img/entities/d46a8")
|
||||
|
||||
cover_blob = {
|
||||
"url": "https://commons.wikimedia.org/wiki/Special:FilePath/The%20Moonstone%201st%20ed.jpg?width=1000",
|
||||
"file": "The Moonstone 1st ed.jpg",
|
||||
"credits": {
|
||||
"text": "Wikimedia Commons",
|
||||
"url": "https://commons.wikimedia.org/wiki/File:The Moonstone 1st ed.jpg",
|
||||
},
|
||||
}
|
||||
|
||||
result = self.connector.get_cover_url(cover_blob)
|
||||
self.assertEqual(
|
||||
result,
|
||||
"https://commons.wikimedia.org/wiki/Special:FilePath/The%20Moonstone%201st%20ed.jpg?width=1000",
|
||||
)
|
||||
|
||||
@responses.activate
|
||||
def test_resolve_keys(self):
|
||||
"""makes an http request"""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://inventaire.io?action=by-uris&uris=wd:Q465821",
|
||||
json={
|
||||
"entities": {
|
||||
"wd:Q465821": {
|
||||
"type": "genre",
|
||||
"labels": {
|
||||
"nl": "briefroman",
|
||||
"en": "epistolary novel",
|
||||
"de-ch": "Briefroman",
|
||||
"en-ca": "Epistolary novel",
|
||||
"nb": "brev- og dagbokroman",
|
||||
},
|
||||
"descriptions": {
|
||||
"en": "novel written as a series of documents",
|
||||
"es": "novela escrita como una serie de documentos",
|
||||
"eo": "romano en la formo de serio de leteroj",
|
||||
},
|
||||
},
|
||||
"redirects": {},
|
||||
}
|
||||
},
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://inventaire.io?action=by-uris&uris=wd:Q208505",
|
||||
json={
|
||||
"entities": {
|
||||
"wd:Q208505": {
|
||||
"type": "genre",
|
||||
"labels": {
|
||||
"en": "crime novel",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
keys = [
|
||||
"wd:Q465821",
|
||||
"wd:Q208505",
|
||||
]
|
||||
result = self.connector.resolve_keys(keys)
|
||||
self.assertEqual(result, ["epistolary novel", "crime novel"])
|
||||
|
||||
def test_isbn_search(self):
|
||||
"""another search type"""
|
||||
search_file = pathlib.Path(__file__).parent.joinpath(
|
||||
"../data/inventaire_isbn_search.json"
|
||||
)
|
||||
search_results = json.loads(search_file.read_bytes())
|
||||
|
||||
results = self.connector.parse_isbn_search_data(search_results)
|
||||
formatted = self.connector.format_isbn_search_result(results[0])
|
||||
|
||||
self.assertEqual(formatted.title, "L'homme aux cercles bleus")
|
||||
self.assertEqual(
|
||||
formatted.key,
|
||||
"https://inventaire.io?action=by-uris&uris=isbn:9782290349229",
|
||||
)
|
||||
self.assertEqual(
|
||||
formatted.cover,
|
||||
"https://covers.inventaire.io/img/entities/12345",
|
||||
)
|
|
@ -84,11 +84,11 @@ class SelfConnector(TestCase):
|
|||
title="Edition 1 Title", parent_work=work
|
||||
)
|
||||
edition_2 = models.Edition.objects.create(
|
||||
title="Edition 2 Title", parent_work=work
|
||||
title="Edition 2 Title",
|
||||
parent_work=work,
|
||||
edition_rank=20, # that's default babey
|
||||
)
|
||||
edition_3 = models.Edition.objects.create(title="Fish", parent_work=work)
|
||||
work.default_edition = edition_2
|
||||
work.save()
|
||||
|
||||
# pick the best edition
|
||||
results = self.connector.search("Edition 1 Title")
|
||||
|
|
5
bookwyrm/tests/data/goodreads-rating.csv
Normal file
5
bookwyrm/tests/data/goodreads-rating.csv
Normal file
|
@ -0,0 +1,5 @@
|
|||
Book Id,Title,Author,Author l-f,Additional Authors,ISBN,ISBN13,My Rating,Average Rating,Publisher,Binding,Number of Pages,Year Published,Original Publication Year,Date Read,Date Added,Bookshelves,Bookshelves with positions,Exclusive Shelf,My Review,Spoiler,Private Notes,Read Count,Recommended For,Recommended By,Owned Copies,Original Purchase Date,Original Purchase Location,Condition,Condition Description,BCID
|
||||
42036538,Gideon the Ninth (The Locked Tomb #1),Tamsyn Muir,"Muir, Tamsyn",,"=""1250313198""","=""9781250313195""",0,4.20,Tor,Hardcover,448,2019,2019,2020/10/25,2020/10/21,,,read,,,,1,,,0,,,,,
|
||||
52691223,Subcutanean,Aaron A. Reed,"Reed, Aaron A.",,"=""""","=""""",0,4.45,,Paperback,232,2020,,2020/03/06,2020/03/05,,,read,,,,1,,,0,,,,,
|
||||
28694510,Patisserie at Home,Mélanie Dupuis,"Dupuis, Mélanie",Anne Cazor,"=""0062445316""","=""9780062445315""",2,4.60,Harper Design,Hardcover,288,2016,,,2019/07/08,,,read,,,,2,,,0,,,,,
|
||||
|
|
45
bookwyrm/tests/data/inventaire_edition.json
Normal file
45
bookwyrm/tests/data/inventaire_edition.json
Normal file
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"entities": {
|
||||
"isbn:9780375757853": {
|
||||
"_id": "7beee121a8d9ac345cdf4e9128577723",
|
||||
"_rev": "2-ac318b04b953ca3894deb77fee28211c",
|
||||
"type": "edition",
|
||||
"labels": {},
|
||||
"claims": {
|
||||
"wdt:P31": [
|
||||
"wd:Q3331189"
|
||||
],
|
||||
"wdt:P212": [
|
||||
"978-0-375-75785-3"
|
||||
],
|
||||
"wdt:P957": [
|
||||
"0-375-75785-6"
|
||||
],
|
||||
"wdt:P407": [
|
||||
"wd:Q1860"
|
||||
],
|
||||
"wdt:P1476": [
|
||||
"The Moonstone"
|
||||
],
|
||||
"wdt:P577": [
|
||||
"2001"
|
||||
],
|
||||
"wdt:P629": [
|
||||
"wd:Q2362563"
|
||||
],
|
||||
"invp:P2": [
|
||||
"d46a8eac7555afa479b8bbb5149f35858e8e19c4"
|
||||
]
|
||||
},
|
||||
"created": 1495452670475,
|
||||
"updated": 1541032981834,
|
||||
"version": 3,
|
||||
"uri": "isbn:9780375757853",
|
||||
"originalLang": "en",
|
||||
"image": {
|
||||
"url": "/img/entities/d46a8eac7555afa479b8bbb5149f35858e8e19c4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"redirects": {}
|
||||
}
|
48
bookwyrm/tests/data/inventaire_isbn_search.json
Normal file
48
bookwyrm/tests/data/inventaire_isbn_search.json
Normal file
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"entities": {
|
||||
"isbn:9782290349229": {
|
||||
"_id": "d59e3e64f92c6340fbb10c5dcf7c0abf",
|
||||
"_rev": "3-079ed51158a001dc74caafb21cff1c22",
|
||||
"type": "edition",
|
||||
"labels": {},
|
||||
"claims": {
|
||||
"wdt:P31": [
|
||||
"wd:Q3331189"
|
||||
],
|
||||
"wdt:P212": [
|
||||
"978-2-290-34922-9"
|
||||
],
|
||||
"wdt:P957": [
|
||||
"2-290-34922-4"
|
||||
],
|
||||
"wdt:P407": [
|
||||
"wd:Q150"
|
||||
],
|
||||
"wdt:P1476": [
|
||||
"L'homme aux cercles bleus"
|
||||
],
|
||||
"wdt:P629": [
|
||||
"wd:Q3203603"
|
||||
],
|
||||
"wdt:P123": [
|
||||
"wd:Q3156592"
|
||||
],
|
||||
"invp:P2": [
|
||||
"57883743aa7c6ad25885a63e6e94349ec4f71562"
|
||||
],
|
||||
"wdt:P577": [
|
||||
"2005-05-01"
|
||||
]
|
||||
},
|
||||
"created": 1485023383338,
|
||||
"updated": 1609171008418,
|
||||
"version": 5,
|
||||
"uri": "isbn:9782290349229",
|
||||
"originalLang": "fr",
|
||||
"image": {
|
||||
"url": "/img/entities/12345"
|
||||
}
|
||||
}
|
||||
},
|
||||
"redirects": {}
|
||||
}
|
111
bookwyrm/tests/data/inventaire_search.json
Normal file
111
bookwyrm/tests/data/inventaire_search.json
Normal file
|
@ -0,0 +1,111 @@
|
|||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "Q7766679",
|
||||
"type": "works",
|
||||
"uri": "wd:Q7766679",
|
||||
"label": "The Stories of Vladimir Nabokov",
|
||||
"description": "book by Vladimir Nabokov",
|
||||
"image": [
|
||||
"ddb32e115a28dcc0465023869ba19f6868ec4042"
|
||||
],
|
||||
"_score": 25.180836,
|
||||
"_popularity": 4
|
||||
},
|
||||
{
|
||||
"id": "Q47407212",
|
||||
"type": "works",
|
||||
"uri": "wd:Q47407212",
|
||||
"label": "Conversations with Vladimir Nabokov",
|
||||
"description": "book edited by Robert Golla",
|
||||
"image": [],
|
||||
"_score": 24.41498,
|
||||
"_popularity": 2
|
||||
},
|
||||
{
|
||||
"id": "Q6956987",
|
||||
"type": "works",
|
||||
"uri": "wd:Q6956987",
|
||||
"label": "Nabokov's Congeries",
|
||||
"description": "book by Vladimir Nabokov",
|
||||
"image": [],
|
||||
"_score": 22.343866,
|
||||
"_popularity": 2
|
||||
},
|
||||
{
|
||||
"id": "Q6956986",
|
||||
"type": "works",
|
||||
"uri": "wd:Q6956986",
|
||||
"label": "Nabokov's Butterflies",
|
||||
"description": "book by Brian Boyd",
|
||||
"image": [],
|
||||
"_score": 22.343866,
|
||||
"_popularity": 2
|
||||
},
|
||||
{
|
||||
"id": "Q47472170",
|
||||
"type": "works",
|
||||
"uri": "wd:Q47472170",
|
||||
"label": "A Reader's Guide to Nabokov's \"Lolita\"",
|
||||
"description": "book by Julian W. Connolly",
|
||||
"image": [],
|
||||
"_score": 19.482553,
|
||||
"_popularity": 2
|
||||
},
|
||||
{
|
||||
"id": "Q7936323",
|
||||
"type": "works",
|
||||
"uri": "wd:Q7936323",
|
||||
"label": "Visiting Mrs Nabokov: And Other Excursions",
|
||||
"description": "book by Martin Amis",
|
||||
"image": [],
|
||||
"_score": 18.684965,
|
||||
"_popularity": 2
|
||||
},
|
||||
{
|
||||
"id": "1732d81bf7376e04da27568a778561a4",
|
||||
"type": "works",
|
||||
"uri": "inv:1732d81bf7376e04da27568a778561a4",
|
||||
"label": "Nabokov's Dark Cinema",
|
||||
"image": [
|
||||
"7512805a53da569b11bf29cc3fb272c969619749"
|
||||
],
|
||||
"_score": 16.56681,
|
||||
"_popularity": 1
|
||||
},
|
||||
{
|
||||
"id": "00f118336b02219e1bddc8fa93c56050",
|
||||
"type": "works",
|
||||
"uri": "inv:00f118336b02219e1bddc8fa93c56050",
|
||||
"label": "The Cambridge Companion to Nabokov",
|
||||
"image": [
|
||||
"0683a059fb95430cfa73334f9eff2ef377f3ae3d"
|
||||
],
|
||||
"_score": 15.502292,
|
||||
"_popularity": 1
|
||||
},
|
||||
{
|
||||
"id": "6e59f968a1cd00dbedeb1964dec47507",
|
||||
"type": "works",
|
||||
"uri": "inv:6e59f968a1cd00dbedeb1964dec47507",
|
||||
"label": "Vladimir Nabokov : selected letters, 1940-1977",
|
||||
"image": [
|
||||
"e3ce8c0ee89d576adf2651a6e5ce55fc6d9f8cb3"
|
||||
],
|
||||
"_score": 15.019735,
|
||||
"_popularity": 1
|
||||
},
|
||||
{
|
||||
"id": "Q127149",
|
||||
"type": "works",
|
||||
"uri": "wd:Q127149",
|
||||
"label": "Lolita",
|
||||
"description": "novel by Vladimir Nabokov",
|
||||
"image": [
|
||||
"51cbfdbf7257b1a6bb3ea3fbb167dbce1fb44a0e"
|
||||
],
|
||||
"_score": 13.458428,
|
||||
"_popularity": 32
|
||||
}
|
||||
]
|
||||
}
|
155
bookwyrm/tests/data/inventaire_work.json
Normal file
155
bookwyrm/tests/data/inventaire_work.json
Normal file
|
@ -0,0 +1,155 @@
|
|||
{
|
||||
"entities": {
|
||||
"wd:Q2362563": {
|
||||
"type": "work",
|
||||
"labels": {
|
||||
"zh-hans": "月亮宝石",
|
||||
"zh-hant": "月亮寶石",
|
||||
"zh-hk": "月光石",
|
||||
"zh-tw": "月光石",
|
||||
"cy": "The Moonstone",
|
||||
"ml": "ദ മൂൺസ്റ്റോൺ",
|
||||
"ja": "月長石",
|
||||
"te": "ది మూన్ స్టోన్",
|
||||
"ru": "Лунный камень",
|
||||
"fr": "La Pierre de lune",
|
||||
"en": "The Moonstone",
|
||||
"es": "La piedra lunar",
|
||||
"it": "La Pietra di Luna",
|
||||
"zh": "月亮宝石",
|
||||
"pl": "Kamień Księżycowy",
|
||||
"sr": "2 Јн",
|
||||
"ta": "moon stone",
|
||||
"ar": "حجر القمر",
|
||||
"fa": "ماهالماس",
|
||||
"uk": "Місячний камінь",
|
||||
"nl": "The Moonstone",
|
||||
"de": "Der Monddiamant",
|
||||
"sl": "Diamant",
|
||||
"sv": "Månstenen",
|
||||
"he": "אבן הירח",
|
||||
"eu": "Ilargi-harriak",
|
||||
"bg": "Лунният камък",
|
||||
"ka": "მთვარის ქვა",
|
||||
"eo": "La Lunŝtono",
|
||||
"hy": "Լուսնաքար",
|
||||
"ro": "Piatra Lunii",
|
||||
"ca": "The Moonstone",
|
||||
"is": "The Moonstone"
|
||||
},
|
||||
"descriptions": {
|
||||
"it": "romanzo scritto da Wilkie Collins",
|
||||
"en": "novel by Wilkie Collins",
|
||||
"de": "Buch von Wilkie Collins",
|
||||
"nl": "boek van Wilkie Collins",
|
||||
"ru": "роман Уилки Коллинза",
|
||||
"he": "רומן מאת וילקי קולינס",
|
||||
"ar": "رواية من تأليف ويلكي كولينز",
|
||||
"fr": "livre de Wilkie Collins",
|
||||
"es": "libro de Wilkie Collins",
|
||||
"bg": "роман на Уилки Колинс",
|
||||
"ka": "უილკი კოლინსის რომანი",
|
||||
"eo": "angalingva romano far Wilkie Collins",
|
||||
"ro": "roman de Wilkie Collins"
|
||||
},
|
||||
"aliases": {
|
||||
"zh": [
|
||||
"月光石"
|
||||
],
|
||||
"ml": [
|
||||
"The Moonstone"
|
||||
],
|
||||
"fr": [
|
||||
"The Moonstone"
|
||||
],
|
||||
"it": [
|
||||
"Il diamante indiano",
|
||||
"La pietra della luna",
|
||||
"La maledizione del diamante indiano"
|
||||
],
|
||||
"ro": [
|
||||
"The Moonstone"
|
||||
]
|
||||
},
|
||||
"claims": {
|
||||
"wdt:P18": [
|
||||
"The Moonstone 1st ed.jpg"
|
||||
],
|
||||
"wdt:P31": [
|
||||
"wd:Q7725634"
|
||||
],
|
||||
"wdt:P50": [
|
||||
"wd:Q210740"
|
||||
],
|
||||
"wdt:P123": [
|
||||
"wd:Q4457856"
|
||||
],
|
||||
"wdt:P136": [
|
||||
"wd:Q465821",
|
||||
"wd:Q208505",
|
||||
"wd:Q10992055"
|
||||
],
|
||||
"wdt:P156": [
|
||||
"wd:Q7228798"
|
||||
],
|
||||
"wdt:P268": [
|
||||
"12496407z"
|
||||
],
|
||||
"wdt:P407": [
|
||||
"wd:Q7979"
|
||||
],
|
||||
"wdt:P577": [
|
||||
"1868"
|
||||
],
|
||||
"wdt:P1433": [
|
||||
"wd:Q21"
|
||||
],
|
||||
"wdt:P1476": [
|
||||
"The Moonstone"
|
||||
],
|
||||
"wdt:P1680": [
|
||||
"A Romance"
|
||||
],
|
||||
"wdt:P2034": [
|
||||
"155"
|
||||
]
|
||||
},
|
||||
"sitelinks": {
|
||||
"arwiki": "حجر القمر (رواية)",
|
||||
"bgwiki": "Лунният камък (роман)",
|
||||
"cywiki": "The Moonstone",
|
||||
"dewiki": "Der Monddiamant",
|
||||
"enwiki": "The Moonstone",
|
||||
"enwikisource": "The Moonstone",
|
||||
"eswiki": "La piedra lunar",
|
||||
"euwiki": "Ilargi-harria",
|
||||
"fawiki": "ماهالماس",
|
||||
"frwiki": "La Pierre de lune (roman de Wilkie Collins)",
|
||||
"hewiki": "אבן הירח",
|
||||
"hywiki": "Լուսնաքար",
|
||||
"iswiki": "The Moonstone",
|
||||
"itwiki": "La pietra di Luna",
|
||||
"jawiki": "月長石 (小説)",
|
||||
"mlwiki": "ദ മൂൺസ്റ്റോൺ",
|
||||
"plwiki": "Kamień Księżycowy (powieść)",
|
||||
"ruwiki": "Лунный камень (роман)",
|
||||
"slwiki": "Diamant (roman)",
|
||||
"srwikisource": "Нови завјет (Караџић) / 2. Јованова",
|
||||
"svwiki": "Månstenen",
|
||||
"tewiki": "ది మూన్స్టోన్",
|
||||
"ukwiki": "Місячний камінь (роман)",
|
||||
"zhwiki": "月亮宝石"
|
||||
},
|
||||
"uri": "wd:Q2362563",
|
||||
"image": {
|
||||
"url": "https://commons.wikimedia.org/wiki/Special:FilePath/The%20Moonstone%201st%20ed.jpg?width=1000",
|
||||
"file": "The Moonstone 1st ed.jpg",
|
||||
"credits": {
|
||||
"text": "Wikimedia Commons",
|
||||
"url": "https://commons.wikimedia.org/wiki/File:The Moonstone 1st ed.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"redirects": {}
|
||||
}
|
|
@ -228,6 +228,32 @@ class GoodreadsImport(TestCase):
|
|||
self.assertEqual(review.published_date.day, 8)
|
||||
self.assertEqual(review.privacy, "unlisted")
|
||||
|
||||
@patch("bookwyrm.activitystreams.ActivityStream.add_status")
|
||||
def test_handle_imported_book_rating(self, _):
|
||||
"""goodreads rating import"""
|
||||
import_job = models.ImportJob.objects.create(user=self.user)
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
"../data/goodreads-rating.csv"
|
||||
)
|
||||
csv_file = open(datafile, "r")
|
||||
entry = list(csv.DictReader(csv_file))[2]
|
||||
entry = self.importer.parse_fields(entry)
|
||||
import_item = models.ImportItem.objects.create(
|
||||
job_id=import_job.id, index=0, data=entry, book=self.book
|
||||
)
|
||||
|
||||
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
|
||||
handle_imported_book(
|
||||
self.importer.service, self.user, import_item, True, "unlisted"
|
||||
)
|
||||
review = models.ReviewRating.objects.get(book=self.book, user=self.user)
|
||||
self.assertIsInstance(review, models.ReviewRating)
|
||||
self.assertEqual(review.rating, 2)
|
||||
self.assertEqual(review.published_date.year, 2019)
|
||||
self.assertEqual(review.published_date.month, 7)
|
||||
self.assertEqual(review.published_date.day, 8)
|
||||
self.assertEqual(review.privacy, "unlisted")
|
||||
|
||||
def test_handle_imported_book_reviews_disabled(self):
|
||||
"""goodreads review import"""
|
||||
import_job = models.ImportJob.objects.create(user=self.user)
|
||||
|
|
|
@ -26,20 +26,23 @@ class BaseModel(TestCase):
|
|||
outbox="https://example.com/users/rat/outbox",
|
||||
)
|
||||
|
||||
class BookWyrmTestModel(base_model.BookWyrmModel):
|
||||
"""just making it not abstract"""
|
||||
|
||||
self.test_model = BookWyrmTestModel()
|
||||
|
||||
def test_remote_id(self):
|
||||
"""these should be generated"""
|
||||
instance = base_model.BookWyrmModel()
|
||||
instance.id = 1
|
||||
expected = instance.get_remote_id()
|
||||
self.assertEqual(expected, "https://%s/bookwyrmmodel/1" % DOMAIN)
|
||||
self.test_model.id = 1
|
||||
expected = self.test_model.get_remote_id()
|
||||
self.assertEqual(expected, "https://%s/bookwyrmtestmodel/1" % DOMAIN)
|
||||
|
||||
def test_remote_id_with_user(self):
|
||||
"""format of remote id when there's a user object"""
|
||||
instance = base_model.BookWyrmModel()
|
||||
instance.user = self.local_user
|
||||
instance.id = 1
|
||||
expected = instance.get_remote_id()
|
||||
self.assertEqual(expected, "https://%s/user/mouse/bookwyrmmodel/1" % DOMAIN)
|
||||
self.test_model.user = self.local_user
|
||||
self.test_model.id = 1
|
||||
expected = self.test_model.get_remote_id()
|
||||
self.assertEqual(expected, "https://%s/user/mouse/bookwyrmtestmodel/1" % DOMAIN)
|
||||
|
||||
def test_set_remote_id(self):
|
||||
"""this function sets remote ids after creation"""
|
||||
|
|
|
@ -84,9 +84,3 @@ class Book(TestCase):
|
|||
self.first_edition.description = "hi"
|
||||
self.first_edition.save()
|
||||
self.assertEqual(self.first_edition.edition_rank, 1)
|
||||
|
||||
# default edition
|
||||
self.work.default_edition = self.first_edition
|
||||
self.work.save()
|
||||
self.first_edition.refresh_from_db()
|
||||
self.assertEqual(self.first_edition.edition_rank, 20)
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from bookwyrm import models, settings
|
||||
from bookwyrm import models
|
||||
|
||||
|
||||
class ReadThrough(TestCase):
|
||||
|
@ -19,8 +19,6 @@ class ReadThrough(TestCase):
|
|||
self.edition = models.Edition.objects.create(
|
||||
title="Example Edition", parent_work=self.work
|
||||
)
|
||||
self.work.default_edition = self.edition
|
||||
self.work.save()
|
||||
|
||||
self.readthrough = models.ReadThrough.objects.create(
|
||||
user=self.user, book=self.edition
|
||||
|
|
|
@ -20,8 +20,6 @@ class ReadThrough(TestCase):
|
|||
self.edition = models.Edition.objects.create(
|
||||
title="Example Edition", parent_work=self.work
|
||||
)
|
||||
self.work.default_edition = self.edition
|
||||
self.work.save()
|
||||
|
||||
self.user = models.User.objects.create_user(
|
||||
"cinco", "cinco@example.com", "seissiete", local=True, localname="cinco"
|
||||
|
|
|
@ -43,7 +43,7 @@ urlpatterns = [
|
|||
re_path("^api/updates/notifications/?$", views.get_notification_count),
|
||||
re_path("^api/updates/stream/(?P<stream>[a-z]+)/?$", views.get_unread_status_count),
|
||||
# authentication
|
||||
re_path(r"^login/?$", views.Login.as_view()),
|
||||
re_path(r"^login/?$", views.Login.as_view(), name="login"),
|
||||
re_path(r"^register/?$", views.Register.as_view()),
|
||||
re_path(r"^logout/?$", views.Logout.as_view()),
|
||||
re_path(r"^password-reset/?$", views.PasswordResetRequest.as_view()),
|
||||
|
|
|
@ -27,7 +27,7 @@ class Author(View):
|
|||
).distinct()
|
||||
data = {
|
||||
"author": author,
|
||||
"books": [b.get_default_edition() for b in books],
|
||||
"books": [b.default_edition for b in books],
|
||||
}
|
||||
return TemplateResponse(request, "author.html", data)
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ class Book(View):
|
|||
return ActivitypubResponse(book.to_activity())
|
||||
|
||||
if isinstance(book, models.Work):
|
||||
book = book.get_default_edition()
|
||||
book = book.default_edition
|
||||
if not book or not book.parent_work:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
|
@ -156,7 +156,6 @@ class EditBook(View):
|
|||
),
|
||||
}
|
||||
)
|
||||
print(data["author_matches"])
|
||||
|
||||
# we're creating a new book
|
||||
if not book:
|
||||
|
|
|
@ -123,7 +123,7 @@ def get_edition(book_id):
|
|||
"""look up a book in the db and return an edition"""
|
||||
book = models.Book.objects.select_subclasses().get(id=book_id)
|
||||
if isinstance(book, models.Work):
|
||||
book = book.get_default_edition()
|
||||
book = book.default_edition
|
||||
return book
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" book list views"""
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.paginator import Paginator
|
||||
|
@ -9,6 +10,7 @@ from django.db.models.functions import Coalesce
|
|||
from django.http import HttpResponseNotFound, HttpResponseBadRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.http import require_POST
|
||||
|
@ -135,7 +137,11 @@ class List(View):
|
|||
|
||||
if query and request.user.is_authenticated:
|
||||
# search for books
|
||||
suggestions = connector_manager.local_search(query, raw=True)
|
||||
suggestions = connector_manager.local_search(
|
||||
query,
|
||||
raw=True,
|
||||
filters=[~Q(parent_work__editions__in=book_list.books.all())],
|
||||
)
|
||||
elif request.user.is_authenticated:
|
||||
# just suggest whatever books are nearby
|
||||
suggestions = request.user.shelfbook_set.filter(
|
||||
|
@ -263,7 +269,10 @@ def add_book(request):
|
|||
# if the book is already on the list, don't flip out
|
||||
pass
|
||||
|
||||
return redirect("list", book_list.id)
|
||||
path = reverse("list", args=[book_list.id])
|
||||
params = request.GET.copy()
|
||||
params["updated"] = True
|
||||
return redirect("{:s}?{:s}".format(path, urlencode(params)))
|
||||
|
||||
|
||||
@require_POST
|
||||
|
|
|
@ -16,7 +16,7 @@ class Notifications(View):
|
|||
notifications = request.user.notification_set.all().order_by("-created_date")
|
||||
unread = [n.id for n in notifications.filter(read=False)]
|
||||
data = {
|
||||
"notifications": notifications,
|
||||
"notifications": notifications[:50],
|
||||
"unread": unread,
|
||||
}
|
||||
notifications.update(read=True)
|
||||
|
|
|
@ -30,27 +30,30 @@ class Search(View):
|
|||
)
|
||||
return JsonResponse([r.json() for r in book_results], safe=False)
|
||||
|
||||
data = {"query": query or ""}
|
||||
|
||||
# use webfinger for mastodon style account@domain.com username
|
||||
if query and re.match(regex.full_username, query):
|
||||
handle_remote_webfinger(query)
|
||||
|
||||
# do a user search
|
||||
user_results = (
|
||||
models.User.viewer_aware_objects(request.user)
|
||||
.annotate(
|
||||
similarity=Greatest(
|
||||
TrigramSimilarity("username", query),
|
||||
TrigramSimilarity("localname", query),
|
||||
if request.user.is_authenticated:
|
||||
data["user_results"] = (
|
||||
models.User.viewer_aware_objects(request.user)
|
||||
.annotate(
|
||||
similarity=Greatest(
|
||||
TrigramSimilarity("username", query),
|
||||
TrigramSimilarity("localname", query),
|
||||
)
|
||||
)
|
||||
.filter(
|
||||
similarity__gt=0.5,
|
||||
)
|
||||
.order_by("-similarity")[:10]
|
||||
)
|
||||
.filter(
|
||||
similarity__gt=0.5,
|
||||
)
|
||||
.order_by("-similarity")[:10]
|
||||
)
|
||||
|
||||
# any relevent lists?
|
||||
list_results = (
|
||||
data["list_results"] = (
|
||||
privacy_filter(
|
||||
request.user,
|
||||
models.List.objects,
|
||||
|
@ -68,11 +71,7 @@ class Search(View):
|
|||
.order_by("-similarity")[:10]
|
||||
)
|
||||
|
||||
book_results = connector_manager.search(query, min_confidence=min_confidence)
|
||||
data = {
|
||||
"book_results": book_results,
|
||||
"user_results": user_results,
|
||||
"list_results": list_results,
|
||||
"query": query or "",
|
||||
}
|
||||
data["book_results"] = connector_manager.search(
|
||||
query, min_confidence=min_confidence
|
||||
)
|
||||
return TemplateResponse(request, "search_results.html", data)
|
||||
|
|
|
@ -106,10 +106,11 @@ class Followers(View):
|
|||
if is_api_request(request):
|
||||
return ActivitypubResponse(user.to_followers_activity(**request.GET))
|
||||
|
||||
paginated = Paginator(user.followers.all(), PAGE_LENGTH)
|
||||
data = {
|
||||
"user": user,
|
||||
"is_self": request.user.id == user.id,
|
||||
"followers": user.followers.all(),
|
||||
"followers": paginated.page(request.GET.get("page", 1)),
|
||||
}
|
||||
return TemplateResponse(request, "user/followers.html", data)
|
||||
|
||||
|
@ -131,10 +132,11 @@ class Following(View):
|
|||
if is_api_request(request):
|
||||
return ActivitypubResponse(user.to_following_activity(**request.GET))
|
||||
|
||||
paginated = Paginator(user.followers.all(), PAGE_LENGTH)
|
||||
data = {
|
||||
"user": user,
|
||||
"is_self": request.user.id == user.id,
|
||||
"following": user.following.all(),
|
||||
"following": paginated.page(request.GET.get("page", 1)),
|
||||
}
|
||||
return TemplateResponse(request, "user/following.html", data)
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue