forked from mirrors/bookwyrm
Merge branch 'main' into suggested_user_logic
This commit is contained in:
commit
6983018d5e
59 changed files with 11724 additions and 3437 deletions
|
@ -7,6 +7,9 @@ DEBUG=true
|
|||
DOMAIN=your.domain.here
|
||||
#EMAIL=your@email.here
|
||||
|
||||
# Used for deciding which editions to prefer
|
||||
DEFAULT_LANGUAGE="English"
|
||||
|
||||
## Leave unset to allow all hosts
|
||||
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ DEBUG=false
|
|||
DOMAIN=your.domain.here
|
||||
EMAIL=your@email.here
|
||||
|
||||
# Used for deciding which editions to prefer
|
||||
DEFAULT_LANGUAGE="English"
|
||||
|
||||
## Leave unset to allow all hosts
|
||||
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
|
||||
|
||||
|
|
|
@ -9,10 +9,11 @@ Permission is hereby granted, free of charge, to any person or organization (the
|
|||
1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software.
|
||||
|
||||
2. The User is one of the following:
|
||||
a. An individual person, laboring for themselves
|
||||
b. A non-profit organization
|
||||
c. An educational institution
|
||||
d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor
|
||||
|
||||
1. An individual person, laboring for themselves
|
||||
2. A non-profit organization
|
||||
3. An educational institution
|
||||
4. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor
|
||||
|
||||
3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote.
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
Binary file not shown.
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
Binary file not shown.
File diff suppressed because it is too large
Load diff
|
@ -1,5 +1,5 @@
|
|||
celery==4.4.2
|
||||
Django==3.1.8
|
||||
Django==3.2.0
|
||||
django-model-utils==4.0.0
|
||||
environs==7.2.0
|
||||
flower==0.9.4
|
||||
|
|
Loading…
Reference in a new issue