mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-12-22 16:16:39 +00:00
Merge branch 'main' into validate-username
This commit is contained in:
commit
50f61f5d19
66 changed files with 2362 additions and 552 deletions
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
@ -1,7 +1,7 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: bookwrym
|
||||
patreon: bookwyrm
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
|
|
|
@ -11,6 +11,7 @@ from .note import Tombstone
|
|||
from .interaction import Boost, Like
|
||||
from .ordered_collection import OrderedCollection, OrderedCollectionPage
|
||||
from .person import Person, PublicKey
|
||||
from .response import ActivitypubResponse
|
||||
from .book import Edition, Work, Author
|
||||
from .verbs import Create, Delete, Undo, Update
|
||||
from .verbs import Follow, Accept, Reject
|
||||
|
|
18
bookwyrm/activitypub/response.py
Normal file
18
bookwyrm/activitypub/response.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.http import JsonResponse
|
||||
|
||||
from .base_activity import ActivityEncoder
|
||||
|
||||
class ActivitypubResponse(JsonResponse):
|
||||
"""
|
||||
A class to be used in any place that's serializing responses for
|
||||
Activitypub enabled clients. Uses JsonResponse under the hood, but already
|
||||
configures some stuff beforehand. Made to be a drop-in replacement of
|
||||
JsonResponse.
|
||||
"""
|
||||
def __init__(self, data, encoder=ActivityEncoder, safe=True,
|
||||
json_dumps_params=None, **kwargs):
|
||||
|
||||
if 'content_type' not in kwargs:
|
||||
kwargs['content_type'] = 'application/activity+json'
|
||||
|
||||
super().__init__(data, encoder, safe, json_dumps_params, **kwargs)
|
|
@ -3,7 +3,7 @@ import json
|
|||
from django.utils.http import http_date
|
||||
import requests
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm import models, settings
|
||||
from bookwyrm.activitypub import ActivityEncoder
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.signatures import make_signature, make_digest
|
||||
|
@ -79,6 +79,7 @@ def sign_and_send(sender, data, destination):
|
|||
'Digest': digest,
|
||||
'Signature': make_signature(sender, destination, now, digest),
|
||||
'Content-Type': 'application/activity+json; charset=utf-8',
|
||||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
if not response.ok:
|
||||
|
|
|
@ -2,3 +2,5 @@
|
|||
from .settings import CONNECTORS
|
||||
from .abstract_connector import ConnectorException
|
||||
from .abstract_connector import get_data, get_image
|
||||
|
||||
from .connector_manager import search, local_search, first_search_result
|
||||
|
|
|
@ -1,20 +1,18 @@
|
|||
''' functionality outline for a book data connector '''
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
import logging
|
||||
from urllib3.exceptions import RequestError
|
||||
|
||||
from django.db import transaction
|
||||
import requests
|
||||
from requests import HTTPError
|
||||
from requests.exceptions import SSLError
|
||||
|
||||
from bookwyrm import activitypub, models
|
||||
|
||||
|
||||
class ConnectorException(HTTPError):
|
||||
''' when the connector can't do what was asked '''
|
||||
from bookwyrm import activitypub, models, settings
|
||||
from .connector_manager import load_more_data, ConnectorException
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
class AbstractMinimalConnector(ABC):
|
||||
''' just the bare bones, for other bookwyrm instances '''
|
||||
def __init__(self, identifier):
|
||||
|
@ -42,11 +40,16 @@ class AbstractMinimalConnector(ABC):
|
|||
'%s%s' % (self.search_url, query),
|
||||
headers={
|
||||
'Accept': 'application/json; charset=utf-8',
|
||||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
if not resp.ok:
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as e:
|
||||
logger.exception(e)
|
||||
raise ConnectorException('Unable to parse json response', e)
|
||||
results = []
|
||||
|
||||
for doc in self.parse_search_data(data)[:10]:
|
||||
|
@ -83,7 +86,6 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
return True
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def get_or_create_book(self, remote_id):
|
||||
''' translate arbitrary json into an Activitypub dataclass '''
|
||||
# first, check if we have the origin_id saved
|
||||
|
@ -116,13 +118,17 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
if not work_data or not edition_data:
|
||||
raise ConnectorException('Unable to load book data: %s' % remote_id)
|
||||
|
||||
# create activitypub object
|
||||
work_activity = activitypub.Work(**work_data)
|
||||
# this will dedupe automatically
|
||||
work = work_activity.to_model(models.Work)
|
||||
for author in self.get_authors_from_data(data):
|
||||
work.authors.add(author)
|
||||
return self.create_edition_from_data(work, edition_data)
|
||||
with transaction.atomic():
|
||||
# create activitypub object
|
||||
work_activity = activitypub.Work(**work_data)
|
||||
# this will dedupe automatically
|
||||
work = work_activity.to_model(models.Work)
|
||||
for author in self.get_authors_from_data(data):
|
||||
work.authors.add(author)
|
||||
|
||||
edition = self.create_edition_from_data(work, edition_data)
|
||||
load_more_data.delay(self.connector.id, work.id)
|
||||
return edition
|
||||
|
||||
|
||||
def create_edition_from_data(self, work, edition_data):
|
||||
|
@ -168,7 +174,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
''' every work needs at least one edition '''
|
||||
|
||||
@abstractmethod
|
||||
def get_work_from_edition_date(self, data):
|
||||
def get_work_from_edition_data(self, data):
|
||||
''' every edition needs a work '''
|
||||
|
||||
@abstractmethod
|
||||
|
@ -196,9 +202,10 @@ def get_data(url):
|
|||
url,
|
||||
headers={
|
||||
'Accept': 'application/json; charset=utf-8',
|
||||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
except RequestError:
|
||||
except (RequestError, SSLError):
|
||||
raise ConnectorException()
|
||||
if not resp.ok:
|
||||
resp.raise_for_status()
|
||||
|
@ -213,7 +220,12 @@ def get_data(url):
|
|||
def get_image(url):
|
||||
''' wrapper for requesting an image '''
|
||||
try:
|
||||
resp = requests.get(url)
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers={
|
||||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
except (RequestError, SSLError):
|
||||
return None
|
||||
if not resp.ok:
|
||||
|
@ -228,12 +240,19 @@ class SearchResult:
|
|||
key: str
|
||||
author: str
|
||||
year: str
|
||||
connector: object
|
||||
confidence: int = 1
|
||||
|
||||
def __repr__(self):
|
||||
return "<SearchResult key={!r} title={!r} author={!r}>".format(
|
||||
self.key, self.title, self.author)
|
||||
|
||||
def json(self):
|
||||
''' serialize a connector for json response '''
|
||||
serialized = asdict(self)
|
||||
del serialized['connector']
|
||||
return serialized
|
||||
|
||||
|
||||
class Mapping:
|
||||
''' associate a local database field with a field in an external dataset '''
|
||||
|
|
|
@ -13,4 +13,5 @@ class Connector(AbstractMinimalConnector):
|
|||
return data
|
||||
|
||||
def format_search_result(self, search_result):
|
||||
search_result['connector'] = self
|
||||
return SearchResult(**search_result)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
''' select and call a connector for whatever book task needs doing '''
|
||||
''' interface with whatever connectors the app has '''
|
||||
import importlib
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
@ -8,43 +8,8 @@ from bookwyrm import models
|
|||
from bookwyrm.tasks import app
|
||||
|
||||
|
||||
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.default_edition
|
||||
return book
|
||||
|
||||
|
||||
def get_or_create_connector(remote_id):
|
||||
''' get the connector related to the author's server '''
|
||||
url = urlparse(remote_id)
|
||||
identifier = url.netloc
|
||||
if not identifier:
|
||||
raise ValueError('Invalid remote id')
|
||||
|
||||
try:
|
||||
connector_info = models.Connector.objects.get(identifier=identifier)
|
||||
except models.Connector.DoesNotExist:
|
||||
connector_info = models.Connector.objects.create(
|
||||
identifier=identifier,
|
||||
connector_file='bookwyrm_connector',
|
||||
base_url='https://%s' % identifier,
|
||||
books_url='https://%s/book' % identifier,
|
||||
covers_url='https://%s/images/covers' % identifier,
|
||||
search_url='https://%s/search?q=' % identifier,
|
||||
priority=2
|
||||
)
|
||||
|
||||
return load_connector(connector_info)
|
||||
|
||||
|
||||
@app.task
|
||||
def load_more_data(book_id):
|
||||
''' background the work of getting all 10,000 editions of LoTR '''
|
||||
book = models.Book.objects.select_subclasses().get(id=book_id)
|
||||
connector = load_connector(book.connector)
|
||||
connector.expand_book_data(book)
|
||||
class ConnectorException(HTTPError):
|
||||
''' when the connector can't do what was asked '''
|
||||
|
||||
|
||||
def search(query, min_confidence=0.1):
|
||||
|
@ -55,7 +20,7 @@ def search(query, min_confidence=0.1):
|
|||
for connector in get_connectors():
|
||||
try:
|
||||
result_set = connector.search(query, min_confidence=min_confidence)
|
||||
except HTTPError:
|
||||
except (HTTPError, ConnectorException):
|
||||
continue
|
||||
|
||||
result_set = [r for r in result_set \
|
||||
|
@ -91,6 +56,38 @@ def get_connectors():
|
|||
yield load_connector(info)
|
||||
|
||||
|
||||
def get_or_create_connector(remote_id):
|
||||
''' get the connector related to the author's server '''
|
||||
url = urlparse(remote_id)
|
||||
identifier = url.netloc
|
||||
if not identifier:
|
||||
raise ValueError('Invalid remote id')
|
||||
|
||||
try:
|
||||
connector_info = models.Connector.objects.get(identifier=identifier)
|
||||
except models.Connector.DoesNotExist:
|
||||
connector_info = models.Connector.objects.create(
|
||||
identifier=identifier,
|
||||
connector_file='bookwyrm_connector',
|
||||
base_url='https://%s' % identifier,
|
||||
books_url='https://%s/book' % identifier,
|
||||
covers_url='https://%s/images/covers' % identifier,
|
||||
search_url='https://%s/search?q=' % identifier,
|
||||
priority=2
|
||||
)
|
||||
|
||||
return load_connector(connector_info)
|
||||
|
||||
|
||||
@app.task
|
||||
def load_more_data(connector_id, book_id):
|
||||
''' background the work of getting all 10,000 editions of LoTR '''
|
||||
connector_info = models.Connector.objects.get(id=connector_id)
|
||||
connector = load_connector(connector_info)
|
||||
book = models.Book.objects.select_subclasses().get(id=book_id)
|
||||
connector.expand_book_data(book)
|
||||
|
||||
|
||||
def load_connector(connector_info):
|
||||
''' instantiate the connector class '''
|
||||
connector = importlib.import_module(
|
|
@ -3,7 +3,8 @@ import re
|
|||
|
||||
from bookwyrm import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult, Mapping
|
||||
from .abstract_connector import ConnectorException, get_data
|
||||
from .abstract_connector import get_data
|
||||
from .connector_manager import ConnectorException
|
||||
from .openlibrary_languages import languages
|
||||
|
||||
|
||||
|
@ -68,7 +69,7 @@ class Connector(AbstractConnector):
|
|||
key = data['key']
|
||||
except KeyError:
|
||||
raise ConnectorException('Invalid book data')
|
||||
return '%s/%s' % (self.books_url, key)
|
||||
return '%s%s' % (self.books_url, key)
|
||||
|
||||
|
||||
def is_work_data(self, data):
|
||||
|
@ -80,17 +81,17 @@ class Connector(AbstractConnector):
|
|||
key = data['key']
|
||||
except KeyError:
|
||||
raise ConnectorException('Invalid book data')
|
||||
url = '%s/%s/editions' % (self.books_url, key)
|
||||
url = '%s%s/editions' % (self.books_url, key)
|
||||
data = get_data(url)
|
||||
return pick_default_edition(data['entries'])
|
||||
|
||||
|
||||
def get_work_from_edition_date(self, data):
|
||||
def get_work_from_edition_data(self, data):
|
||||
try:
|
||||
key = data['works'][0]['key']
|
||||
except (IndexError, KeyError):
|
||||
raise ConnectorException('No work found for edition')
|
||||
url = '%s/%s' % (self.books_url, key)
|
||||
url = '%s%s' % (self.books_url, key)
|
||||
return get_data(url)
|
||||
|
||||
|
||||
|
@ -100,14 +101,14 @@ class Connector(AbstractConnector):
|
|||
author_blob = author_blob.get('author', author_blob)
|
||||
# this id is "/authors/OL1234567A"
|
||||
author_id = author_blob['key']
|
||||
url = '%s/%s.json' % (self.base_url, author_id)
|
||||
url = '%s%s' % (self.base_url, author_id)
|
||||
yield self.get_or_create_author(url)
|
||||
|
||||
|
||||
def get_cover_url(self, cover_blob):
|
||||
''' ask openlibrary for the cover '''
|
||||
cover_id = cover_blob[0]
|
||||
image_name = '%s-M.jpg' % cover_id
|
||||
image_name = '%s-L.jpg' % cover_id
|
||||
return '%s/b/id/%s' % (self.covers_url, image_name)
|
||||
|
||||
|
||||
|
@ -123,13 +124,14 @@ class Connector(AbstractConnector):
|
|||
title=search_result.get('title'),
|
||||
key=key,
|
||||
author=', '.join(author),
|
||||
connector=self,
|
||||
year=search_result.get('first_publish_year'),
|
||||
)
|
||||
|
||||
|
||||
def load_edition_data(self, olkey):
|
||||
''' query openlibrary for editions of a work '''
|
||||
url = '%s/works/%s/editions.json' % (self.books_url, olkey)
|
||||
url = '%s/works/%s/editions' % (self.books_url, olkey)
|
||||
return get_data(url)
|
||||
|
||||
|
||||
|
@ -149,7 +151,7 @@ def get_description(description_blob):
|
|||
''' descriptions can be a string or a dict '''
|
||||
if isinstance(description_blob, dict):
|
||||
return description_blob.get('value')
|
||||
return description_blob
|
||||
return description_blob
|
||||
|
||||
|
||||
def get_openlibrary_key(key):
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
''' using a bookwyrm instance as a source of book data '''
|
||||
from functools import reduce
|
||||
import operator
|
||||
|
||||
from django.contrib.postgres.search import SearchRank, SearchVector
|
||||
from django.db.models import F
|
||||
from django.db.models import Count, F, Q
|
||||
|
||||
from bookwyrm import models
|
||||
from .abstract_connector import AbstractConnector, SearchResult
|
||||
|
@ -9,38 +12,18 @@ from .abstract_connector import AbstractConnector, SearchResult
|
|||
class Connector(AbstractConnector):
|
||||
''' instantiate a connector '''
|
||||
def search(self, query, min_confidence=0.1):
|
||||
''' right now you can't search bookwyrm sorry, but when
|
||||
that gets implemented it will totally rule '''
|
||||
vector = SearchVector('title', weight='A') +\
|
||||
SearchVector('subtitle', weight='B') +\
|
||||
SearchVector('authors__name', weight='C') +\
|
||||
SearchVector('isbn_13', weight='A') +\
|
||||
SearchVector('isbn_10', weight='A') +\
|
||||
SearchVector('openlibrary_key', weight='C') +\
|
||||
SearchVector('goodreads_key', weight='C') +\
|
||||
SearchVector('asin', weight='C') +\
|
||||
SearchVector('oclc_number', weight='C') +\
|
||||
SearchVector('remote_id', weight='C') +\
|
||||
SearchVector('description', weight='D') +\
|
||||
SearchVector('series', weight='D')
|
||||
|
||||
results = models.Edition.objects.annotate(
|
||||
search=vector
|
||||
).annotate(
|
||||
rank=SearchRank(vector, query)
|
||||
).filter(
|
||||
rank__gt=min_confidence
|
||||
).order_by('-rank')
|
||||
|
||||
# remove non-default editions, if possible
|
||||
results = results.filter(parent_work__default_edition__id=F('id')) \
|
||||
or results
|
||||
|
||||
''' search your local database '''
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query)
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(query, min_confidence)
|
||||
search_results = []
|
||||
for book in results[:10]:
|
||||
search_results.append(
|
||||
self.format_search_result(book)
|
||||
)
|
||||
for result in results:
|
||||
search_results.append(self.format_search_result(result))
|
||||
if len(search_results) >= 10:
|
||||
break
|
||||
search_results.sort(key=lambda r: r.confidence, reverse=True)
|
||||
return search_results
|
||||
|
||||
|
||||
|
@ -51,31 +34,74 @@ class Connector(AbstractConnector):
|
|||
author=search_result.author_text,
|
||||
year=search_result.published_date.year if \
|
||||
search_result.published_date else None,
|
||||
confidence=search_result.rank,
|
||||
connector=self,
|
||||
confidence=search_result.rank if \
|
||||
hasattr(search_result, 'rank') else 1,
|
||||
)
|
||||
|
||||
|
||||
def get_remote_id_from_data(self, data):
|
||||
pass
|
||||
|
||||
def is_work_data(self, data):
|
||||
pass
|
||||
|
||||
def get_edition_from_work_data(self, data):
|
||||
pass
|
||||
|
||||
def get_work_from_edition_date(self, data):
|
||||
def get_work_from_edition_data(self, data):
|
||||
pass
|
||||
|
||||
def get_authors_from_data(self, data):
|
||||
return None
|
||||
|
||||
def get_cover_from_data(self, data):
|
||||
return None
|
||||
|
||||
def parse_search_data(self, data):
|
||||
''' it's already in the right format, don't even worry about it '''
|
||||
return data
|
||||
|
||||
def expand_book_data(self, book):
|
||||
pass
|
||||
|
||||
|
||||
def search_identifiers(query):
|
||||
''' tries remote_id, isbn; defined as dedupe fields on the model '''
|
||||
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))
|
||||
).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
|
||||
|
||||
|
||||
def search_title_author(query, min_confidence):
|
||||
''' searches for title and author '''
|
||||
vector = SearchVector('title', weight='A') +\
|
||||
SearchVector('subtitle', weight='B') +\
|
||||
SearchVector('authors__name', weight='C') +\
|
||||
SearchVector('series', weight='D')
|
||||
|
||||
results = models.Edition.objects.annotate(
|
||||
search=vector
|
||||
).annotate(
|
||||
rank=SearchRank(vector, query)
|
||||
).filter(
|
||||
rank__gt=min_confidence
|
||||
).order_by('-rank')
|
||||
|
||||
# when there are multiple editions of the same work, pick the closest
|
||||
editions_of_work = results.values(
|
||||
'parent_work'
|
||||
).annotate(
|
||||
Count('parent_work')
|
||||
).values_list('parent_work')
|
||||
|
||||
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
|
||||
# if mutliple books have the top rank, pick the default edition
|
||||
if default_rank == editions.first().rank:
|
||||
yield default.first()
|
||||
else:
|
||||
yield editions.first()
|
||||
|
|
|
@ -8,8 +8,6 @@ from bookwyrm.models import ImportJob, ImportItem
|
|||
from bookwyrm.status import create_notification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# TODO: remove or increase once we're confident it's not causing problems.
|
||||
MAX_ENTRIES = 500
|
||||
|
||||
|
||||
def create_job(user, csv_file, include_reviews, privacy):
|
||||
|
@ -19,12 +17,13 @@ def create_job(user, csv_file, include_reviews, privacy):
|
|||
include_reviews=include_reviews,
|
||||
privacy=privacy
|
||||
)
|
||||
for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]):
|
||||
for index, entry in enumerate(list(csv.DictReader(csv_file))):
|
||||
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
|
||||
raise ValueError('Author, title, and isbn must be in data.')
|
||||
ImportItem(job=job, index=index, data=entry).save()
|
||||
return job
|
||||
|
||||
|
||||
def create_retry_job(user, original_job, items):
|
||||
''' retry items that didn't import '''
|
||||
job = ImportJob.objects.create(
|
||||
|
@ -37,6 +36,7 @@ def create_retry_job(user, original_job, items):
|
|||
ImportItem(job=job, index=item.index, data=item.data).save()
|
||||
return job
|
||||
|
||||
|
||||
def start_import(job):
|
||||
''' initalizes a csv import job '''
|
||||
result = import_data.delay(job.id)
|
||||
|
@ -49,7 +49,6 @@ def import_data(job_id):
|
|||
''' does the actual lookup work in a celery task '''
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
try:
|
||||
results = []
|
||||
for item in job.items.all():
|
||||
try:
|
||||
item.resolve()
|
||||
|
@ -61,7 +60,6 @@ def import_data(job_id):
|
|||
|
||||
if item.book:
|
||||
item.save()
|
||||
results.append(item)
|
||||
|
||||
# shelves book and handles reviews
|
||||
outgoing.handle_imported_book(
|
||||
|
|
|
@ -185,11 +185,15 @@ def handle_create(activity):
|
|||
''' someone did something, good on them '''
|
||||
# deduplicate incoming activities
|
||||
activity = activity['object']
|
||||
status_id = activity['id']
|
||||
status_id = activity.get('id')
|
||||
if models.Status.objects.filter(remote_id=status_id).count():
|
||||
return
|
||||
|
||||
serializer = activitypub.activity_objects[activity['type']]
|
||||
try:
|
||||
serializer = activitypub.activity_objects[activity['type']]
|
||||
except KeyError:
|
||||
return
|
||||
|
||||
activity = serializer(**activity)
|
||||
try:
|
||||
model = models.activity_models[activity.type]
|
||||
|
|
83
bookwyrm/management/commands/deduplicate_book_data.py
Normal file
83
bookwyrm/management/commands/deduplicate_book_data.py
Normal file
|
@ -0,0 +1,83 @@
|
|||
''' PROCEED WITH CAUTION: uses deduplication fields to permanently
|
||||
merge book data objects '''
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Count
|
||||
from bookwyrm import models
|
||||
|
||||
|
||||
def update_related(canonical, obj):
|
||||
''' update all the models with fk to the object being removed '''
|
||||
# move related models to canonical
|
||||
related_models = [
|
||||
(r.remote_field.name, r.related_model) for r in \
|
||||
canonical._meta.related_objects]
|
||||
for (related_field, related_model) in related_models:
|
||||
related_objs = related_model.objects.filter(
|
||||
**{related_field: obj})
|
||||
for related_obj in related_objs:
|
||||
print(
|
||||
'replacing in',
|
||||
related_model.__name__,
|
||||
related_field,
|
||||
related_obj.id
|
||||
)
|
||||
try:
|
||||
setattr(related_obj, related_field, canonical)
|
||||
related_obj.save()
|
||||
except TypeError:
|
||||
getattr(related_obj, related_field).add(canonical)
|
||||
getattr(related_obj, related_field).remove(obj)
|
||||
|
||||
|
||||
def copy_data(canonical, obj):
|
||||
''' try to get the most data possible '''
|
||||
for data_field in obj._meta.get_fields():
|
||||
if not hasattr(data_field, 'activitypub_field'):
|
||||
continue
|
||||
data_value = getattr(obj, data_field.name)
|
||||
if not data_value:
|
||||
continue
|
||||
if not getattr(canonical, data_field.name):
|
||||
print('setting data field', data_field.name, data_value)
|
||||
setattr(canonical, data_field.name, data_value)
|
||||
canonical.save()
|
||||
|
||||
|
||||
def dedupe_model(model):
|
||||
''' combine duplicate editions and update related models '''
|
||||
fields = model._meta.get_fields()
|
||||
dedupe_fields = [f for f in fields if \
|
||||
hasattr(f, 'deduplication_field') and f.deduplication_field]
|
||||
for field in dedupe_fields:
|
||||
dupes = model.objects.values(field.name).annotate(
|
||||
Count(field.name)
|
||||
).filter(**{'%s__count__gt' % field.name: 1})
|
||||
|
||||
for dupe in dupes:
|
||||
value = dupe[field.name]
|
||||
if not value or value == '':
|
||||
continue
|
||||
print('----------')
|
||||
print(dupe)
|
||||
objs = model.objects.filter(
|
||||
**{field.name: value}
|
||||
).order_by('id')
|
||||
canonical = objs.first()
|
||||
print('keeping', canonical.remote_id)
|
||||
for obj in objs[1:]:
|
||||
print(obj.remote_id)
|
||||
copy_data(canonical, obj)
|
||||
update_related(canonical, obj)
|
||||
# remove the outdated entry
|
||||
obj.delete()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
''' dedplucate allllll the book data models '''
|
||||
help = 'merges duplicate book data'
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
''' run deudplications '''
|
||||
dedupe_model(models.Edition)
|
||||
dedupe_model(models.Work)
|
||||
dedupe_model(models.Author)
|
|
@ -9,8 +9,11 @@ from .connector import Connector
|
|||
from .shelf import Shelf, ShelfBook
|
||||
|
||||
from .status import Status, GeneratedNote, Review, Comment, Quotation
|
||||
from .status import Favorite, Boost, Notification, ReadThrough
|
||||
from .status import Boost
|
||||
from .attachment import Image
|
||||
from .favorite import Favorite
|
||||
from .notification import Notification
|
||||
from .readthrough import ReadThrough
|
||||
|
||||
from .tag import Tag, UserTag
|
||||
|
||||
|
@ -25,3 +28,6 @@ from .site import SiteSettings, SiteInvite, PasswordReset
|
|||
cls_members = inspect.getmembers(sys.modules[__name__], inspect.isclass)
|
||||
activity_models = {c[1].activity_serializer.__name__: c[1] \
|
||||
for c in cls_members if hasattr(c[1], 'activity_serializer')}
|
||||
|
||||
status_models = [
|
||||
c.__name__ for (_, c) in activity_models.items() if issubclass(c, Status)]
|
||||
|
|
|
@ -35,6 +35,11 @@ class BookWyrmModel(models.Model):
|
|||
''' this is just here to provide default fields for other models '''
|
||||
abstract = True
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
''' how to link to this object in the local app '''
|
||||
return self.get_remote_id().replace('https://%s' % DOMAIN, '')
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
#pylint: disable=unused-argument
|
||||
|
@ -104,7 +109,7 @@ class ActivitypubMixin:
|
|||
not field.deduplication_field:
|
||||
continue
|
||||
|
||||
value = data.get(field.activitypub_field)
|
||||
value = data.get(field.get_activitypub_field())
|
||||
if not value:
|
||||
continue
|
||||
filters.append({field.name: value})
|
||||
|
@ -237,7 +242,9 @@ class OrderedCollectionPageMixin(ActivitypubMixin):
|
|||
).serialize()
|
||||
|
||||
|
||||
def to_ordered_collection_page(queryset, remote_id, id_only=False, page=1):
|
||||
# pylint: disable=unused-argument
|
||||
def to_ordered_collection_page(
|
||||
queryset, remote_id, id_only=False, page=1, **kwargs):
|
||||
''' serialize and pagiante a queryset '''
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
|
||||
|
|
|
@ -126,6 +126,14 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
''' in case the default edition is not set '''
|
||||
return self.default_edition or self.editions.first()
|
||||
|
||||
def to_edition_list(self, **kwargs):
|
||||
''' an ordered collection of editions '''
|
||||
return self.to_ordered_collection(
|
||||
self.editions.order_by('-updated_date').all(),
|
||||
remote_id='%s/editions' % self.remote_id,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Work
|
||||
serialize_reverse_fields = [('editions', 'editions')]
|
||||
deserialize_reverse_fields = [('editions', 'editions')]
|
||||
|
|
26
bookwyrm/models/favorite.py
Normal file
26
bookwyrm/models/favorite.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
''' like/fav/star a status '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from .base_model import ActivitypubMixin, BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
class Favorite(ActivitypubMixin, BookWyrmModel):
|
||||
''' fav'ing a post '''
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='actor')
|
||||
status = fields.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, activitypub_field='object')
|
||||
|
||||
activity_serializer = activitypub.Like
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
''' can't fav things twice '''
|
||||
unique_together = ('user', 'status')
|
|
@ -6,7 +6,7 @@ from django.contrib.postgres.fields import JSONField
|
|||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import books_manager
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from bookwyrm.models import ReadThrough, User, Book
|
||||
from .fields import PrivacyLevels
|
||||
|
||||
|
@ -71,12 +71,12 @@ class ImportItem(models.Model):
|
|||
|
||||
def get_book_from_isbn(self):
|
||||
''' search by isbn '''
|
||||
search_result = books_manager.first_search_result(
|
||||
search_result = connector_manager.first_search_result(
|
||||
self.isbn, min_confidence=0.999
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return books_manager.get_or_create_book(search_result.key)
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
|
||||
|
||||
|
@ -86,12 +86,12 @@ class ImportItem(models.Model):
|
|||
self.data['Title'],
|
||||
self.data['Author']
|
||||
)
|
||||
search_result = books_manager.first_search_result(
|
||||
search_result = connector_manager.first_search_result(
|
||||
search_term, min_confidence=0.999
|
||||
)
|
||||
if search_result:
|
||||
# raises ConnectorException
|
||||
return books_manager.get_or_create_book(search_result.key)
|
||||
return search_result.connector.get_or_create_book(search_result.key)
|
||||
return None
|
||||
|
||||
|
||||
|
|
33
bookwyrm/models/notification.py
Normal file
33
bookwyrm/models/notification.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
''' alert a user to activity '''
|
||||
from django.db import models
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
||||
NotificationType = models.TextChoices(
|
||||
'NotificationType',
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
''' you've been tagged, liked, followed, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
related_book = models.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, null=True)
|
||||
related_user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT, null=True, related_name='related_user')
|
||||
related_status = models.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, null=True)
|
||||
related_import = models.ForeignKey(
|
||||
'ImportJob', on_delete=models.PROTECT, null=True)
|
||||
read = models.BooleanField(default=False)
|
||||
notification_type = models.CharField(
|
||||
max_length=255, choices=NotificationType.choices)
|
||||
|
||||
class Meta:
|
||||
''' checks if notifcation is in enum list for valid types '''
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(notification_type__in=NotificationType.values),
|
||||
name="notification_type_valid",
|
||||
)
|
||||
]
|
26
bookwyrm/models/readthrough.py
Normal file
26
bookwyrm/models/readthrough.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
''' progress in a book '''
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||
pages_read = models.IntegerField(
|
||||
null=True,
|
||||
blank=True)
|
||||
start_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
finish_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
|
@ -54,7 +54,7 @@ class UserRelationship(ActivitypubMixin, BookWyrmModel):
|
|||
|
||||
|
||||
def to_reject_activity(self):
|
||||
''' generate an Accept for this follow request '''
|
||||
''' generate a Reject for this follow request '''
|
||||
return activitypub.Reject(
|
||||
id=self.get_remote_id(status='rejects'),
|
||||
actor=self.user_object.remote_id,
|
||||
|
|
|
@ -118,7 +118,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
|||
activity['attachment'] = [
|
||||
image_serializer(b.cover, b.alt_text) \
|
||||
for b in self.mention_books.all()[:4] if b.cover]
|
||||
if hasattr(self, 'book'):
|
||||
if hasattr(self, 'book') and self.book.cover:
|
||||
activity['attachment'].append(
|
||||
image_serializer(self.book.cover, self.book.alt_text)
|
||||
)
|
||||
|
@ -222,26 +222,6 @@ class Review(Status):
|
|||
pure_type = 'Article'
|
||||
|
||||
|
||||
class Favorite(ActivitypubMixin, BookWyrmModel):
|
||||
''' fav'ing a post '''
|
||||
user = fields.ForeignKey(
|
||||
'User', on_delete=models.PROTECT, activitypub_field='actor')
|
||||
status = fields.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, activitypub_field='object')
|
||||
|
||||
activity_serializer = activitypub.Like
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
''' can't fav things twice '''
|
||||
unique_together = ('user', 'status')
|
||||
|
||||
|
||||
class Boost(Status):
|
||||
''' boost'ing a post '''
|
||||
boosted_status = fields.ForeignKey(
|
||||
|
@ -268,54 +248,3 @@ class Boost(Status):
|
|||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
|
||||
|
||||
class ReadThrough(BookWyrmModel):
|
||||
''' Store progress through a book in the database. '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||
pages_read = models.IntegerField(
|
||||
null=True,
|
||||
blank=True)
|
||||
start_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
finish_date = models.DateTimeField(
|
||||
blank=True,
|
||||
null=True)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
self.user.save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
NotificationType = models.TextChoices(
|
||||
'NotificationType',
|
||||
'FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT')
|
||||
|
||||
class Notification(BookWyrmModel):
|
||||
''' you've been tagged, liked, followed, etc '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
related_book = models.ForeignKey(
|
||||
'Edition', on_delete=models.PROTECT, null=True)
|
||||
related_user = models.ForeignKey(
|
||||
'User',
|
||||
on_delete=models.PROTECT, null=True, related_name='related_user')
|
||||
related_status = models.ForeignKey(
|
||||
'Status', on_delete=models.PROTECT, null=True)
|
||||
related_import = models.ForeignKey(
|
||||
'ImportJob', on_delete=models.PROTECT, null=True)
|
||||
read = models.BooleanField(default=False)
|
||||
notification_type = models.CharField(
|
||||
max_length=255, choices=NotificationType.choices)
|
||||
|
||||
class Meta:
|
||||
''' checks if notifcation is in enum list for valid types '''
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=models.Q(notification_type__in=NotificationType.values),
|
||||
name="notification_type_valid",
|
||||
)
|
||||
]
|
||||
|
|
|
@ -17,7 +17,9 @@ class Tag(OrderedCollectionMixin, BookWyrmModel):
|
|||
@classmethod
|
||||
def book_queryset(cls, identifier):
|
||||
''' county of books associated with this tag '''
|
||||
return cls.objects.filter(identifier=identifier)
|
||||
return cls.objects.filter(
|
||||
identifier=identifier
|
||||
).order_by('-updated_date')
|
||||
|
||||
@property
|
||||
def collection_queryset(self):
|
||||
|
@ -64,7 +66,7 @@ class UserTag(BookWyrmModel):
|
|||
id='%s#remove' % self.remote_id,
|
||||
actor=user.remote_id,
|
||||
object=self.book.to_activity(),
|
||||
target=self.to_activity(),
|
||||
target=self.remote_id,
|
||||
).serialize()
|
||||
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
''' database schema for user data '''
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.dispatch import receiver
|
||||
|
@ -107,11 +108,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
activity_serializer = activitypub.Person
|
||||
|
||||
def to_outbox(self, **kwargs):
|
||||
def to_outbox(self, filter_type=None, **kwargs):
|
||||
''' an ordered collection of statuses '''
|
||||
queryset = Status.objects.filter(
|
||||
if filter_type:
|
||||
filter_class = apps.get_model(
|
||||
'bookwyrm.%s' % filter_type, require_ready=True)
|
||||
if not issubclass(filter_class, Status):
|
||||
raise TypeError(
|
||||
'filter_status_class must be a subclass of models.Status')
|
||||
queryset = filter_class.objects
|
||||
else:
|
||||
queryset = Status.objects
|
||||
|
||||
queryset = queryset.filter(
|
||||
user=self,
|
||||
deleted=False,
|
||||
privacy__in=['public', 'unlisted'],
|
||||
).select_subclasses().order_by('-published_date')
|
||||
return self.to_ordered_collection(queryset, \
|
||||
remote_id=self.outbox, **kwargs)
|
||||
|
@ -119,14 +131,22 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
def to_following_activity(self, **kwargs):
|
||||
''' activitypub following list '''
|
||||
remote_id = '%s/following' % self.remote_id
|
||||
return self.to_ordered_collection(self.following.all(), \
|
||||
remote_id=remote_id, id_only=True, **kwargs)
|
||||
return self.to_ordered_collection(
|
||||
self.following.order_by('-updated_date').all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_followers_activity(self, **kwargs):
|
||||
''' activitypub followers list '''
|
||||
remote_id = '%s/followers' % self.remote_id
|
||||
return self.to_ordered_collection(self.followers.all(), \
|
||||
remote_id=remote_id, id_only=True, **kwargs)
|
||||
return self.to_ordered_collection(
|
||||
self.followers.order_by('-updated_date').all(),
|
||||
remote_id=remote_id,
|
||||
id_only=True,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_activity(self):
|
||||
''' override default AP serializer to add context object
|
||||
|
@ -165,6 +185,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
''' this model doesn't inherit bookwyrm model, so here we are '''
|
||||
return '/user/%s' % (self.localname or self.username)
|
||||
|
||||
|
||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||
''' public and private keys for a user '''
|
||||
|
@ -270,7 +295,7 @@ def get_or_create_remote_server(domain):
|
|||
@app.task
|
||||
def get_remote_reviews(outbox):
|
||||
''' ingest reviews by a new remote bookwyrm user '''
|
||||
outbox_page = outbox + '?page=true'
|
||||
outbox_page = outbox + '?page=true&type=Review'
|
||||
data = get_data(outbox_page)
|
||||
|
||||
# TODO: pagination?
|
||||
|
|
|
@ -2,8 +2,10 @@
|
|||
import re
|
||||
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.http import HttpResponseNotFound, JsonResponse
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_GET
|
||||
from markdown import markdown
|
||||
from requests import HTTPError
|
||||
|
||||
|
@ -20,19 +22,16 @@ from bookwyrm.utils import regex
|
|||
|
||||
|
||||
@csrf_exempt
|
||||
@require_GET
|
||||
def outbox(request, username):
|
||||
''' outbox for the requested user '''
|
||||
if request.method != 'GET':
|
||||
return HttpResponseNotFound()
|
||||
user = get_object_or_404(models.User, localname=username)
|
||||
filter_type = request.GET.get('type')
|
||||
if filter_type not in models.status_models:
|
||||
filter_type = None
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(localname=username)
|
||||
except models.User.DoesNotExist:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
# collection overview
|
||||
return JsonResponse(
|
||||
user.to_outbox(**request.GET),
|
||||
user.to_outbox(**request.GET, filter_type=filter_type),
|
||||
encoder=activitypub.ActivityEncoder
|
||||
)
|
||||
|
||||
|
@ -42,6 +41,9 @@ def handle_remote_webfinger(query):
|
|||
user = None
|
||||
|
||||
# usernames could be @user@domain or user@domain
|
||||
if not query:
|
||||
return None
|
||||
|
||||
if query[0] == '@':
|
||||
query = query[1:]
|
||||
|
||||
|
@ -164,22 +166,23 @@ def handle_imported_book(user, item, include_reviews, privacy):
|
|||
if not item.book:
|
||||
return
|
||||
|
||||
if item.shelf:
|
||||
existing_shelf = models.ShelfBook.objects.filter(
|
||||
book=item.book, added_by=user).exists()
|
||||
|
||||
# shelve the book if it hasn't been shelved already
|
||||
if item.shelf and not existing_shelf:
|
||||
desired_shelf = models.Shelf.objects.get(
|
||||
identifier=item.shelf,
|
||||
user=user
|
||||
)
|
||||
# shelve the book if it hasn't been shelved already
|
||||
shelf_book, created = models.ShelfBook.objects.get_or_create(
|
||||
shelf_book = models.ShelfBook.objects.create(
|
||||
book=item.book, shelf=desired_shelf, added_by=user)
|
||||
if created:
|
||||
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
|
||||
broadcast(user, shelf_book.to_add_activity(user), privacy=privacy)
|
||||
|
||||
# only add new read-throughs if the item isn't already shelved
|
||||
for read in item.reads:
|
||||
read.book = item.book
|
||||
read.user = user
|
||||
read.save()
|
||||
for read in item.reads:
|
||||
read.book = item.book
|
||||
read.user = user
|
||||
read.save()
|
||||
|
||||
if include_reviews and (item.rating or item.review):
|
||||
review_title = 'Review of {!r} on Goodreads'.format(
|
||||
|
@ -218,8 +221,65 @@ def handle_status(user, form):
|
|||
status.save()
|
||||
|
||||
# inspect the text for user tags
|
||||
matches = []
|
||||
for match in re.finditer(regex.username, status.content):
|
||||
content = status.content
|
||||
for (mention_text, mention_user) in find_mentions(content):
|
||||
# add them to status mentions fk
|
||||
status.mention_users.add(mention_user)
|
||||
|
||||
# turn the mention into a link
|
||||
content = re.sub(
|
||||
r'%s([^@]|$)' % mention_text,
|
||||
r'<a href="%s">%s</a>\g<1>' % \
|
||||
(mention_user.remote_id, mention_text),
|
||||
content)
|
||||
|
||||
# add reply parent to mentions and notify
|
||||
if status.reply_parent:
|
||||
status.mention_users.add(status.reply_parent.user)
|
||||
for mention_user in status.reply_parent.mention_users.all():
|
||||
status.mention_users.add(mention_user)
|
||||
|
||||
if status.reply_parent.user.local:
|
||||
create_notification(
|
||||
status.reply_parent.user,
|
||||
'REPLY',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
|
||||
# deduplicate mentions
|
||||
status.mention_users.set(set(status.mention_users.all()))
|
||||
# create mention notifications
|
||||
for mention_user in status.mention_users.all():
|
||||
if status.reply_parent and mention_user == status.reply_parent.user:
|
||||
continue
|
||||
if mention_user.local:
|
||||
create_notification(
|
||||
mention_user,
|
||||
'MENTION',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
|
||||
# don't apply formatting to generated notes
|
||||
if not isinstance(status, models.GeneratedNote):
|
||||
status.content = to_markdown(content)
|
||||
# do apply formatting to quotes
|
||||
if hasattr(status, 'quote'):
|
||||
status.quote = to_markdown(status.quote)
|
||||
|
||||
status.save()
|
||||
|
||||
broadcast(user, status.to_create_activity(user), software='bookwyrm')
|
||||
|
||||
# re-format the activity for non-bookwyrm servers
|
||||
remote_activity = status.to_create_activity(user, pure=True)
|
||||
broadcast(user, remote_activity, software='other')
|
||||
|
||||
|
||||
def find_mentions(content):
|
||||
''' detect @mentions in raw status content '''
|
||||
for match in re.finditer(regex.strict_username, content):
|
||||
username = match.group().strip().split('@')[1:]
|
||||
if len(username) == 1:
|
||||
# this looks like a local user (@user), fill in the domain
|
||||
|
@ -230,44 +290,7 @@ def handle_status(user, form):
|
|||
if not mention_user:
|
||||
# we can ignore users we don't know about
|
||||
continue
|
||||
matches.append((match.group(), mention_user.remote_id))
|
||||
# add them to status mentions fk
|
||||
status.mention_users.add(mention_user)
|
||||
# create notification if the mentioned user is local
|
||||
if mention_user.local:
|
||||
create_notification(
|
||||
mention_user,
|
||||
'MENTION',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
# add mentions
|
||||
content = status.content
|
||||
for (username, url) in matches:
|
||||
content = re.sub(
|
||||
r'%s([^@])' % username,
|
||||
r'<a href="%s">%s</a>\g<1>' % (url, username),
|
||||
content)
|
||||
if not isinstance(status, models.GeneratedNote):
|
||||
status.content = to_markdown(content)
|
||||
if hasattr(status, 'quote'):
|
||||
status.quote = to_markdown(status.quote)
|
||||
status.save()
|
||||
|
||||
# notify reply parent or tagged users
|
||||
if status.reply_parent and status.reply_parent.user.local:
|
||||
create_notification(
|
||||
status.reply_parent.user,
|
||||
'REPLY',
|
||||
related_user=user,
|
||||
related_status=status
|
||||
)
|
||||
|
||||
broadcast(user, status.to_create_activity(user), software='bookwyrm')
|
||||
|
||||
# re-format the activity for non-bookwyrm servers
|
||||
remote_activity = status.to_create_activity(user, pure=True)
|
||||
broadcast(user, remote_activity, software='other')
|
||||
yield (match.group(), mention_user)
|
||||
|
||||
|
||||
def to_markdown(content):
|
||||
|
@ -284,21 +307,6 @@ def to_markdown(content):
|
|||
return sanitizer.get_output()
|
||||
|
||||
|
||||
def handle_tag(user, tag):
|
||||
''' tag a book '''
|
||||
broadcast(user, tag.to_add_activity(user))
|
||||
|
||||
|
||||
def handle_untag(user, book, name):
|
||||
''' tag a book '''
|
||||
book = models.Book.objects.get(id=book)
|
||||
tag = models.Tag.objects.get(name=name, book=book, user=user)
|
||||
tag_activity = tag.to_remove_activity(user)
|
||||
tag.delete()
|
||||
|
||||
broadcast(user, tag_activity)
|
||||
|
||||
|
||||
def handle_favorite(user, status):
|
||||
''' a user likes a status '''
|
||||
try:
|
||||
|
|
|
@ -3,8 +3,11 @@ import os
|
|||
|
||||
from environs import Env
|
||||
|
||||
import requests
|
||||
|
||||
env = Env()
|
||||
DOMAIN = env('DOMAIN')
|
||||
VERSION = '0.0.1'
|
||||
|
||||
PAGE_LENGTH = env('PAGE_LENGTH', 15)
|
||||
|
||||
|
@ -150,3 +153,6 @@ STATIC_URL = '/static/'
|
|||
STATIC_ROOT = os.path.join(BASE_DIR, env('STATIC_ROOT', 'static'))
|
||||
MEDIA_URL = '/images/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, env('MEDIA_ROOT', 'images'))
|
||||
|
||||
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
|
||||
requests.utils.default_user_agent(), VERSION, DOMAIN)
|
||||
|
|
|
@ -67,6 +67,13 @@ input.toggle-control:checked ~ .modal.toggle-content {
|
|||
width: max-content;
|
||||
max-width: 250px;
|
||||
}
|
||||
.cover-container.is-large {
|
||||
height: max-content;
|
||||
max-width: 500px;
|
||||
}
|
||||
.cover-container.is-large img {
|
||||
max-height: 500px;
|
||||
}
|
||||
.cover-container.is-medium {
|
||||
height: 150px;
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
</div>
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
<div class="column is-narrow">
|
||||
<a href="/author/{{ author.id }}/edit">
|
||||
<a href="{{ author.local_path }}/edit">
|
||||
<span class="icon icon-pencil">
|
||||
<span class="is-sr-only">Edit Author</span>
|
||||
</span>
|
||||
|
|
|
@ -166,10 +166,10 @@
|
|||
{% for rating in ratings %}
|
||||
<div class="column">
|
||||
<div class="media">
|
||||
<div class="media-left">{% include 'snippets/avatar.html' %}</div>
|
||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
||||
<div class="media-content">
|
||||
<div>
|
||||
{% include 'snippets/username.html' %}
|
||||
{% include 'snippets/username.html' with user=rating.user %}
|
||||
</div>
|
||||
<div class="field is-grouped mb-0">
|
||||
<div>rated it</div>
|
||||
|
|
80
bookwyrm/templates/discover.html
Normal file
80
bookwyrm/templates/discover.html
Normal file
|
@ -0,0 +1,80 @@
|
|||
{% extends 'layout.html' %}
|
||||
{% block content %}
|
||||
|
||||
{% if not request.user.is_authenticated %}
|
||||
<div class="block">
|
||||
<h1 class="title has-text-centered">{{ site.name }}: Social Reading and Reviewing</h1>
|
||||
</div>
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-7 is-parent">
|
||||
<div class="tile is-child box">
|
||||
{% include 'snippets/about.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-5 is-parent">
|
||||
<div class="tile is-child box has-background-primary-light">
|
||||
{% if site.allow_registration %}
|
||||
<h2 class="title">Join {{ site.name }}</h2>
|
||||
<form name="register" method="post" action="/user-register">
|
||||
{% include 'snippets/register_form.html' %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h2 class="title">This instance is closed</h2>
|
||||
<p>Contact an administrator to get an invite</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="block">
|
||||
<h1 class="title has-text-centered">Discover</h1>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="block is-hidden-tablet">
|
||||
<h2 class="title has-text-centered">Recent Books</h2>
|
||||
</div>
|
||||
|
||||
<section class="tile is-ancestor">
|
||||
<div class="tile is-vertical">
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/large-book.html' with book=books.0 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.1 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.2 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-vertical">
|
||||
<div class="tile">
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.3 %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent is-6">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/small-book.html' with book=books.4 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile is-parent">
|
||||
<div class="tile is-child box has-background-white-ter">
|
||||
{% include 'snippets/discover/large-book.html' with book=books.5 %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
|
@ -31,7 +31,7 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
{% if not following.count %}
|
||||
<div>No one is following {{ user|username }}</div>
|
||||
<div>{{ user|username }} isn't following any users</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
|
|
@ -21,8 +21,6 @@
|
|||
</div>
|
||||
<button class="button is-primary" type="submit">Import</button>
|
||||
</form>
|
||||
<p>
|
||||
Imports are limited in size, and only the first {{ limit }} items will be imported.
|
||||
</div>
|
||||
|
||||
<div class="content block">
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar" role="navigation" aria-label="main navigation">
|
||||
<nav class="navbar container" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="/static/images/logo-small.png" alt="Home page">
|
||||
|
@ -63,33 +63,45 @@
|
|||
<div class="navbar-end">
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<div class="navbar-link"><p>
|
||||
<div class="navbar-link" role="button" aria-expanded=false" onclick="toggleMenu(this)" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
|
||||
{% include 'snippets/avatar.html' with user=request.user %}
|
||||
{% include 'snippets/username.html' with user=request.user %}
|
||||
</p></div>
|
||||
<div class="navbar-dropdown">
|
||||
<a href="/direct-messages" class="navbar-item">
|
||||
Direct messages
|
||||
</a>
|
||||
<a href="/user/{{request.user.localname}}" class="navbar-item">
|
||||
Profile
|
||||
</a>
|
||||
<a href="/user-edit" class="navbar-item">
|
||||
Settings
|
||||
</a>
|
||||
<a href="/import" class="navbar-item">
|
||||
Import books
|
||||
</a>
|
||||
<ul class="navbar-dropdown" id="navbar-dropdown">
|
||||
<li>
|
||||
<a href="/direct-messages" class="navbar-item">
|
||||
Direct messages
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user/{{request.user.localname}}" class="navbar-item">
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/user-edit" class="navbar-item">
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/import" class="navbar-item">
|
||||
Import books
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
<a href="/invite" class="navbar-item">
|
||||
Invites
|
||||
</a>
|
||||
<li>
|
||||
<a href="/invite" class="navbar-item">
|
||||
Invites
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<hr class="navbar-divider">
|
||||
<a href="/logout" class="navbar-item">
|
||||
Log out
|
||||
</a>
|
||||
</div>
|
||||
<li>
|
||||
<a href="/logout" class="navbar-item">
|
||||
Log out
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-item">
|
||||
<a href="/notifications">
|
||||
|
@ -105,48 +117,73 @@
|
|||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{% else %}
|
||||
<div class="navbar-item">
|
||||
<div class="buttons">
|
||||
<a href="/login" class="button is-primary">
|
||||
Join
|
||||
</a>
|
||||
{% if request.path != '/login' and request.path != '/login/' %}
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<form name="login" method="post" action="/user-login">
|
||||
{% csrf_token %}
|
||||
<div class="field is-grouped">
|
||||
<div class="control">
|
||||
<label class="is-sr-only" for="id_username">Username:</label>
|
||||
<input type="text" name="username" maxlength="150" class="input" required="" id="id_username" placeholder="username">
|
||||
</div>
|
||||
<div class="control">
|
||||
<label class="is-sr-only" for="id_password">Username:</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="password">
|
||||
<p class="help"><a href="/password-reset">Forgot your password?</a></p>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if site.allow_registration and request.path != '' and request.path != '/' %}
|
||||
<div class="column is-narrow">
|
||||
<a href="/" class="button is-link">
|
||||
Join
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<div class="section">
|
||||
<div class="section container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<p>
|
||||
<a href="/about">About this server</a>
|
||||
</p>
|
||||
{% if site.admin_email %}
|
||||
<p>
|
||||
<a href="mailto:{{ site.admin_email }}">Contact site admin</a>
|
||||
</p>
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<p>
|
||||
<a href="/about">About this server</a>
|
||||
</p>
|
||||
{% if site.admin_email %}
|
||||
<p>
|
||||
<a href="mailto:{{ site.admin_email }}">Contact site admin</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if site.support_link %}
|
||||
<div class="column">
|
||||
<span class="icon icon-heart"></span>
|
||||
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if site.support_link %}
|
||||
<div class="column">
|
||||
<span class="icon icon-heart"></span>
|
||||
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="column">
|
||||
BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.
|
||||
<div class="column">
|
||||
BookWyrm is open source software. You can contribute or report issues on <a href="https://github.com/mouse-reeve/bookwyrm">GitHub</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
|
@ -154,4 +191,3 @@
|
|||
<script src="/static/js/shared.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
|
|
@ -22,16 +22,16 @@
|
|||
{% include 'snippets/username.html' with user=notification.related_user %}
|
||||
{% if notification.notification_type == 'FAVORITE' %}
|
||||
favorited your
|
||||
<a href="{{ notification.related_status.remote_id}}">status</a>
|
||||
<a href="{{ notification.related_status.local_path }}">status</a>
|
||||
|
||||
{% elif notification.notification_type == 'MENTION' %}
|
||||
mentioned you in a
|
||||
<a href="{{ notification.related_status.remote_id}}">status</a>
|
||||
<a href="{{ notification.related_status.local_path }}">status</a>
|
||||
|
||||
{% elif notification.notification_type == 'REPLY' %}
|
||||
<a href="{{ notification.related_status.remote_id}}">replied</a>
|
||||
<a href="{{ notification.related_status.local_path }}">replied</a>
|
||||
to your
|
||||
<a href="{{ notification.related_status.reply_parent.remote_id}}">status</a>
|
||||
<a href="{{ notification.related_status.reply_parent.local_path }}">status</a>
|
||||
{% elif notification.notification_type == 'FOLLOW' %}
|
||||
followed you
|
||||
{% elif notification.notification_type == 'FOLLOW_REQUEST' %}
|
||||
|
@ -41,7 +41,7 @@
|
|||
</div>
|
||||
|
||||
{% elif notification.notification_type == 'BOOST' %}
|
||||
boosted your <a href="{{ notification.related_status.remote_id}}">status</a>
|
||||
boosted your <a href="{{ notification.related_status.local_path }}">status</a>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
your <a href="/import-status/{{ notification.related_import.id }}">import</a> completed.
|
||||
|
@ -54,7 +54,7 @@
|
|||
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-white{% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %} has-text-black{% else %}-bis has-text-grey-dark{% endif %}{% endif %}">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<a href="{{ notification.related_status.remote_id }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
|
||||
<a href="{{ notification.related_status.local_path }}">{{ notification.related_status.content | safe | truncatewords_html:10 }}</a>
|
||||
</div>
|
||||
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
|
||||
{{ notification.related_status.published_date | post_date }}
|
||||
|
|
|
@ -122,7 +122,7 @@
|
|||
|
||||
<div class="block">
|
||||
<div>
|
||||
{% include 'snippets/shelf.html' with shelf=shelf ratings=ratings %}
|
||||
{% include 'snippets/shelf.html' with shelf=shelf books=books ratings=ratings %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
<h1 class="title">About {{ site.name }}</h1>
|
||||
<div class="block">
|
||||
<img src="/static/images/logo.png" alt="BookWyrm">
|
||||
<div class="columns">
|
||||
<div class="column is-narrow is-hidden-mobile">
|
||||
<figure class="block">
|
||||
<img src="/static/images/logo.png" alt="BookWyrm">
|
||||
</figure>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="block">
|
||||
{{ site.instance_description | safe }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="block">
|
||||
{{ site.instance_description }}
|
||||
</p>
|
||||
|
|
18
bookwyrm/templates/snippets/discover/large-book.html
Normal file
18
bookwyrm/templates/snippets/discover/large-book.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% if book %}
|
||||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/book_cover.html' with book=book size="large" %}
|
||||
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
|
||||
</div>
|
||||
<div class="column">
|
||||
<h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
|
||||
{% if book.authors %}
|
||||
<p class="subtitle is-5">by {% include 'snippets/authors.html' with book=book %}</p>
|
||||
{% endif %}
|
||||
{% if book|book_description %}
|
||||
<blockquote class="content">{{ book|book_description|to_markdown|safe|truncatewords_html:50 }}</blockquote>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
11
bookwyrm/templates/snippets/discover/small-book.html
Normal file
11
bookwyrm/templates/snippets/discover/small-book.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% if book %}
|
||||
{% include 'snippets/book_cover.html' with book=book %}
|
||||
{% include 'snippets/stars.html' with rating=ratings|dict_key:book.id %}
|
||||
|
||||
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
|
||||
{% if book.authors %}
|
||||
<p class="subtitle is-6">by {% include 'snippets/authors.html' with book=book %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
|
@ -1,13 +1,15 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% if request.user|follow_request_exists:user %}
|
||||
<form action="/accept-follow-request/" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ user.username }}">
|
||||
<button class="button is-primary is-small" type="submit">Accept</button>
|
||||
</form>
|
||||
<form action="/delete-follow-request/" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ user.username }}">
|
||||
<button class="button is-danger is-light is-small" type="submit" class="warning">Delete</button>
|
||||
</form>
|
||||
<div class="field is-grouped">
|
||||
<form action="/accept-follow-request/" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ user.username }}">
|
||||
<button class="button is-link is-small" type="submit">Accept</button>
|
||||
</form>
|
||||
<form action="/delete-follow-request/" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ user.username }}">
|
||||
<button class="button is-danger is-light is-small" type="submit" class="warning">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,20 +1,21 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% with activity.id|uuid as uuid %}
|
||||
{% with status.id|uuid as uuid %}
|
||||
<form class="is-flex-grow-1" name="reply" action="/reply" method="post" onsubmit="return reply(event)">
|
||||
<div class="columns">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="reply_parent" value="{{ activity.id }}">
|
||||
<input type="hidden" name="reply_parent" value="{{ status.id }}">
|
||||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
<div class="column">
|
||||
|
||||
{% include 'snippets/content_warning_field.html' with parent_status=activity %}
|
||||
{% include 'snippets/content_warning_field.html' with parent_status=status %}
|
||||
<label for="id_content_{{ status.id }}-{{ uuid }}" class="is-sr-only">Reply</label>
|
||||
<div class="field">
|
||||
<textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ activity.id }}-{{ uuid }}" required="true"></textarea>
|
||||
<textarea class="textarea" name="content" placeholder="Leave a comment..." id="id_content_{{ status.id }}-{{ uuid }}" required="true"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div class="field">
|
||||
{% include 'snippets/privacy_select.html' with current=activity.privacy %}
|
||||
{% include 'snippets/privacy_select.html' with current=status.privacy %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="button is-primary" type="submit">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{% load humanize %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% if shelf.books.all|length > 0 %}
|
||||
{% if books|length > 0 %}
|
||||
<table class="table is-striped is-fullwidth">
|
||||
|
||||
<tr class="book-preview">
|
||||
|
@ -34,7 +34,7 @@
|
|||
</th>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% for book in shelf.books.all %}
|
||||
{% for book in books %}
|
||||
<tr class="book-preview">
|
||||
<td>
|
||||
{% include 'snippets/book_cover.html' with book=book size="small" %}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<input type="hidden" name="name" value="{{ tag.tag.name }}">
|
||||
|
||||
<div class="tags has-addons">
|
||||
<a class="tag" href="/tag/{{ tag.tag.identifier|urlencode }}">
|
||||
<a class="tag" href="{{ tag.tag.local_path }}">
|
||||
{{ tag.tag.name }}
|
||||
</a>
|
||||
{% if tag.tag.identifier in user_tags %}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<div class="column is-narrow">
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<a href="/user/{{ user|username }}">
|
||||
<a href="{{ user.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=user large=True %}
|
||||
</a>
|
||||
</div>
|
||||
|
@ -14,8 +14,8 @@
|
|||
<p><a href="{{ user.remote_id }}">{{ user.username }}</a></p>
|
||||
<p>Joined {{ user.created_date | naturaltime }}</p>
|
||||
<p>
|
||||
<a href="/user/{{ user | username }}/followers">{{ user.followers.count }} follower{{ user.followers.count | pluralize }}</a>,
|
||||
<a href="/user/{{ user | username }}/following">{{ user.following.count }} following</a>
|
||||
<a href="{{ user.local_path }}/followers">{{ user.followers.count }} follower{{ user.followers.count | pluralize }}</a>,
|
||||
<a href="{{ user.local_path }}/following">{{ user.following.count }} following</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
{% load bookwyrm_tags %}
|
||||
<a href="/user/{{ user | username }}" class="user">{% if user.name %}{{ user.name }}{% else %}{{ user | username }}{% endif %}</a>{% if possessive %}'s{% endif %}{% if show_full and user.name or show_full and user.localname %} ({{ user.username }}){% endif %}
|
||||
<a href="{{ user.local_path }}" class="user">{% if user.name %}{{ user.name }}{% else %}{{ user | username }}{% endif %}</a>{% if possessive %}'s{% endif %}{% if show_full and user.name or show_full and user.localname %} ({{ user.username }}){% endif %}
|
||||
|
|
|
@ -24,11 +24,11 @@
|
|||
{% for shelf in shelves %}
|
||||
<div class="column is-narrow">
|
||||
<h3>{{ shelf.name }}
|
||||
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.remote_id }}">See all {{ shelf.size }}</a>)</small>{% endif %}</h3>
|
||||
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.local_path }}">See all {{ shelf.size }}</a>)</small>{% endif %}</h3>
|
||||
<div class="is-mobile field is-grouped">
|
||||
{% for book in shelf.books %}
|
||||
<div class="control">
|
||||
<a href="/book/{{ book.id }}">
|
||||
<a href="{{ book.local_path }}">
|
||||
{% include 'snippets/book_cover.html' with book=book size="medium" %}
|
||||
</a>
|
||||
</div>
|
||||
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<small><a href="/user/{{ user.localname }}/shelves">See all {{ shelf_count }} shelves</a></small>
|
||||
<small><a href="{{ user.local_path }}/shelves">See all {{ shelf_count }} shelves</a></small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
|
|
@ -110,7 +110,7 @@ def get_uuid(identifier):
|
|||
return '%s%s' % (identifier, uuid4())
|
||||
|
||||
|
||||
@register.filter(name="post_date")
|
||||
@register.filter(name='post_date')
|
||||
def time_since(date):
|
||||
''' concise time ago function '''
|
||||
if not isinstance(date, datetime):
|
||||
|
@ -133,13 +133,20 @@ def time_since(date):
|
|||
return '%ds' % delta.seconds
|
||||
|
||||
|
||||
@register.filter(name="to_markdown")
|
||||
@register.filter(name='to_markdown')
|
||||
def get_markdown(content):
|
||||
''' convert markdown to html '''
|
||||
if content:
|
||||
return to_markdown(content)
|
||||
return None
|
||||
|
||||
@register.filter(name='mentions')
|
||||
def get_mentions(status, user):
|
||||
''' anyone tagged or replied to in this status '''
|
||||
mentions = set([status.user] + list(status.mention_users.all()))
|
||||
return ' '.join(
|
||||
'@' + get_user_identifier(m) for m in mentions if not m == user)
|
||||
|
||||
@register.simple_tag(takes_context=True)
|
||||
def active_shelf(context, book):
|
||||
''' check what shelf a user has a book on, if any '''
|
||||
|
|
|
@ -1,60 +1,114 @@
|
|||
''' testing book data connectors '''
|
||||
from unittest.mock import patch
|
||||
from django.test import TestCase
|
||||
import responses
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.connectors import abstract_connector
|
||||
from bookwyrm.connectors.abstract_connector import Mapping
|
||||
from bookwyrm.connectors.openlibrary import Connector
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
class AbstractConnector(TestCase):
|
||||
''' generic code for connecting to outside data sources '''
|
||||
def setUp(self):
|
||||
self.book = models.Edition.objects.create(title='Example Edition')
|
||||
|
||||
models.Connector.objects.create(
|
||||
''' we need an example connector '''
|
||||
self.connector_info = models.Connector.objects.create(
|
||||
identifier='example.com',
|
||||
connector_file='openlibrary',
|
||||
base_url='https://example.com',
|
||||
books_url='https:/example.com',
|
||||
covers_url='https://example.com',
|
||||
books_url='https://example.com/books',
|
||||
covers_url='https://example.com/covers',
|
||||
search_url='https://example.com/search?q=',
|
||||
)
|
||||
self.connector = Connector('example.com')
|
||||
|
||||
self.data = {
|
||||
'title': 'Unused title',
|
||||
'ASIN': 'A00BLAH',
|
||||
'isbn_10': '1234567890',
|
||||
'isbn_13': 'blahhh',
|
||||
'blah': 'bip',
|
||||
'format': 'hardcover',
|
||||
'series': ['one', 'two'],
|
||||
work_data = {
|
||||
'id': 'abc1',
|
||||
'title': 'Test work',
|
||||
'type': 'work',
|
||||
'openlibraryKey': 'OL1234W',
|
||||
}
|
||||
self.connector.key_mappings = [
|
||||
Mapping('isbn_10'),
|
||||
Mapping('isbn_13'),
|
||||
Mapping('lccn'),
|
||||
Mapping('asin'),
|
||||
self.work_data = work_data
|
||||
edition_data = {
|
||||
'id': 'abc2',
|
||||
'title': 'Test edition',
|
||||
'type': 'edition',
|
||||
'openlibraryKey': 'OL1234M',
|
||||
}
|
||||
self.edition_data = edition_data
|
||||
|
||||
class TestConnector(abstract_connector.AbstractConnector):
|
||||
''' nothing added here '''
|
||||
def format_search_result(self, search_result):
|
||||
return search_result
|
||||
def parse_search_data(self, data):
|
||||
return data
|
||||
def is_work_data(self, data):
|
||||
return data['type'] == 'work'
|
||||
def get_edition_from_work_data(self, data):
|
||||
return edition_data
|
||||
def get_work_from_edition_data(self, data):
|
||||
return work_data
|
||||
def get_authors_from_data(self, data):
|
||||
return []
|
||||
def expand_book_data(self, book):
|
||||
pass
|
||||
self.connector = TestConnector('example.com')
|
||||
self.connector.book_mappings = [
|
||||
Mapping('id'),
|
||||
Mapping('title'),
|
||||
Mapping('openlibraryKey'),
|
||||
]
|
||||
|
||||
|
||||
def test_create_mapping(self):
|
||||
mapping = Mapping('isbn')
|
||||
self.assertEqual(mapping.local_field, 'isbn')
|
||||
self.assertEqual(mapping.remote_field, 'isbn')
|
||||
self.assertEqual(mapping.formatter('bb'), 'bb')
|
||||
self.book = models.Edition.objects.create(
|
||||
title='Test Book', remote_id='https://example.com/book/1234',
|
||||
openlibrary_key='OL1234M')
|
||||
|
||||
|
||||
def test_create_mapping_with_remote(self):
|
||||
mapping = Mapping('isbn', remote_field='isbn13')
|
||||
self.assertEqual(mapping.local_field, 'isbn')
|
||||
self.assertEqual(mapping.remote_field, 'isbn13')
|
||||
self.assertEqual(mapping.formatter('bb'), 'bb')
|
||||
def test_abstract_connector_init(self):
|
||||
''' barebones connector for search with defaults '''
|
||||
self.assertIsInstance(self.connector.book_mappings, list)
|
||||
|
||||
|
||||
def test_create_mapping_with_formatter(self):
|
||||
formatter = lambda x: 'aa' + x
|
||||
mapping = Mapping('isbn', formatter=formatter)
|
||||
self.assertEqual(mapping.local_field, 'isbn')
|
||||
self.assertEqual(mapping.remote_field, 'isbn')
|
||||
self.assertEqual(mapping.formatter, formatter)
|
||||
self.assertEqual(mapping.formatter('bb'), 'aabb')
|
||||
def test_is_available(self):
|
||||
''' this isn't used.... '''
|
||||
self.assertTrue(self.connector.is_available())
|
||||
self.connector.max_query_count = 1
|
||||
self.connector.connector.query_count = 2
|
||||
self.assertFalse(self.connector.is_available())
|
||||
|
||||
|
||||
def test_get_or_create_book_existing(self):
|
||||
''' find an existing book by remote/origin id '''
|
||||
self.assertEqual(models.Book.objects.count(), 1)
|
||||
self.assertEqual(
|
||||
self.book.remote_id, 'https://%s/book/%d' % (DOMAIN, self.book.id))
|
||||
self.assertEqual(
|
||||
self.book.origin_id, 'https://example.com/book/1234')
|
||||
|
||||
# dedupe by origin id
|
||||
result = self.connector.get_or_create_book(
|
||||
'https://example.com/book/1234')
|
||||
self.assertEqual(models.Book.objects.count(), 1)
|
||||
self.assertEqual(result, self.book)
|
||||
|
||||
# dedupe by remote id
|
||||
result = self.connector.get_or_create_book(
|
||||
'https://%s/book/%d' % (DOMAIN, self.book.id))
|
||||
self.assertEqual(models.Book.objects.count(), 1)
|
||||
self.assertEqual(result, self.book)
|
||||
|
||||
@responses.activate
|
||||
def test_get_or_create_book_deduped(self):
|
||||
''' load remote data and deduplicate '''
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://example.com/book/abcd',
|
||||
json=self.edition_data
|
||||
)
|
||||
with patch(
|
||||
'bookwyrm.connectors.abstract_connector.load_more_data.delay'):
|
||||
result = self.connector.get_or_create_book(
|
||||
'https://example.com/book/abcd')
|
||||
self.assertEqual(result, self.book)
|
||||
self.assertEqual(models.Edition.objects.count(), 1)
|
||||
self.assertEqual(models.Edition.objects.count(), 1)
|
||||
|
|
100
bookwyrm/tests/connectors/test_abstract_minimal_connector.py
Normal file
100
bookwyrm/tests/connectors/test_abstract_minimal_connector.py
Normal file
|
@ -0,0 +1,100 @@
|
|||
''' testing book data connectors '''
|
||||
from django.test import TestCase
|
||||
import responses
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.connectors import abstract_connector
|
||||
from bookwyrm.connectors.abstract_connector import Mapping, SearchResult
|
||||
|
||||
|
||||
class AbstractConnector(TestCase):
|
||||
''' generic code for connecting to outside data sources '''
|
||||
def setUp(self):
|
||||
''' we need an example connector '''
|
||||
self.connector_info = models.Connector.objects.create(
|
||||
identifier='example.com',
|
||||
connector_file='openlibrary',
|
||||
base_url='https://example.com',
|
||||
books_url='https://example.com/books',
|
||||
covers_url='https://example.com/covers',
|
||||
search_url='https://example.com/search?q=',
|
||||
)
|
||||
|
||||
class TestConnector(abstract_connector.AbstractMinimalConnector):
|
||||
''' nothing added here '''
|
||||
def format_search_result(self, search_result):
|
||||
return search_result
|
||||
def get_or_create_book(self, remote_id):
|
||||
pass
|
||||
def parse_search_data(self, data):
|
||||
return data
|
||||
self.test_connector = TestConnector('example.com')
|
||||
|
||||
|
||||
def test_abstract_minimal_connector_init(self):
|
||||
''' barebones connector for search with defaults '''
|
||||
connector = self.test_connector
|
||||
self.assertEqual(connector.connector, self.connector_info)
|
||||
self.assertEqual(connector.base_url, 'https://example.com')
|
||||
self.assertEqual(connector.books_url, 'https://example.com/books')
|
||||
self.assertEqual(connector.covers_url, 'https://example.com/covers')
|
||||
self.assertEqual(connector.search_url, 'https://example.com/search?q=')
|
||||
self.assertIsNone(connector.name)
|
||||
self.assertEqual(connector.identifier, 'example.com')
|
||||
self.assertIsNone(connector.max_query_count)
|
||||
self.assertFalse(connector.local)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_search(self):
|
||||
''' makes an http request to the outside service '''
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://example.com/search?q=a%20book%20title',
|
||||
json=['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'],
|
||||
status=200)
|
||||
results = self.test_connector.search('a book title')
|
||||
self.assertEqual(len(results), 10)
|
||||
self.assertEqual(results[0], 'a')
|
||||
self.assertEqual(results[1], 'b')
|
||||
self.assertEqual(results[2], 'c')
|
||||
|
||||
|
||||
def test_search_result(self):
|
||||
''' a class that stores info about a search result '''
|
||||
result = SearchResult(
|
||||
title='Title',
|
||||
key='https://example.com/book/1',
|
||||
author='Author Name',
|
||||
year='1850',
|
||||
connector=self.test_connector,
|
||||
)
|
||||
# there's really not much to test here, it's just a dataclass
|
||||
self.assertEqual(result.confidence, 1)
|
||||
self.assertEqual(result.title, 'Title')
|
||||
|
||||
|
||||
def test_create_mapping(self):
|
||||
''' maps remote fields for book data to bookwyrm activitypub fields '''
|
||||
mapping = Mapping('isbn')
|
||||
self.assertEqual(mapping.local_field, 'isbn')
|
||||
self.assertEqual(mapping.remote_field, 'isbn')
|
||||
self.assertEqual(mapping.formatter('bb'), 'bb')
|
||||
|
||||
|
||||
def test_create_mapping_with_remote(self):
|
||||
''' the remote field is different than the local field '''
|
||||
mapping = Mapping('isbn', remote_field='isbn13')
|
||||
self.assertEqual(mapping.local_field, 'isbn')
|
||||
self.assertEqual(mapping.remote_field, 'isbn13')
|
||||
self.assertEqual(mapping.formatter('bb'), 'bb')
|
||||
|
||||
|
||||
def test_create_mapping_with_formatter(self):
|
||||
''' a function is provided to modify the data '''
|
||||
formatter = lambda x: 'aa' + x
|
||||
mapping = Mapping('isbn', formatter=formatter)
|
||||
self.assertEqual(mapping.local_field, 'isbn')
|
||||
self.assertEqual(mapping.remote_field, 'isbn')
|
||||
self.assertEqual(mapping.formatter, formatter)
|
||||
self.assertEqual(mapping.formatter('bb'), 'aabb')
|
|
@ -31,6 +31,7 @@ class BookWyrmConnector(TestCase):
|
|||
|
||||
|
||||
def test_format_search_result(self):
|
||||
''' create a SearchResult object from search response json '''
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
'../data/fr_search.json')
|
||||
search_data = json.loads(datafile.read_bytes())
|
||||
|
@ -43,3 +44,4 @@ class BookWyrmConnector(TestCase):
|
|||
self.assertEqual(result.key, 'https://example.com/book/122')
|
||||
self.assertEqual(result.author, 'Susanna Clarke')
|
||||
self.assertEqual(result.year, 2017)
|
||||
self.assertEqual(result.connector, self.connector)
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
''' interface between the app and various connectors '''
|
||||
from django.test import TestCase
|
||||
|
||||
from bookwyrm import books_manager, models
|
||||
from bookwyrm.connectors.bookwyrm_connector import Connector as BookWyrmConnector
|
||||
from bookwyrm.connectors.self_connector import Connector as SelfConnector
|
||||
from bookwyrm import models
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from bookwyrm.connectors.bookwyrm_connector \
|
||||
import Connector as BookWyrmConnector
|
||||
from bookwyrm.connectors.self_connector \
|
||||
import Connector as SelfConnector
|
||||
|
||||
|
||||
class Book(TestCase):
|
||||
class ConnectorManager(TestCase):
|
||||
''' interface between the app and various connectors '''
|
||||
def setUp(self):
|
||||
''' we'll need some books and a connector info entry '''
|
||||
self.work = models.Work.objects.create(
|
||||
title='Example Work'
|
||||
)
|
||||
|
@ -28,53 +34,50 @@ class Book(TestCase):
|
|||
covers_url='http://test.com/',
|
||||
)
|
||||
|
||||
def test_get_edition(self):
|
||||
edition = books_manager.get_edition(self.edition.id)
|
||||
self.assertEqual(edition, self.edition)
|
||||
|
||||
|
||||
def test_get_edition_work(self):
|
||||
edition = books_manager.get_edition(self.work.id)
|
||||
self.assertEqual(edition, self.edition)
|
||||
|
||||
|
||||
def test_get_or_create_connector(self):
|
||||
''' loads a connector if the data source is known or creates one '''
|
||||
remote_id = 'https://example.com/object/1'
|
||||
connector = books_manager.get_or_create_connector(remote_id)
|
||||
connector = connector_manager.get_or_create_connector(remote_id)
|
||||
self.assertIsInstance(connector, BookWyrmConnector)
|
||||
self.assertEqual(connector.identifier, 'example.com')
|
||||
self.assertEqual(connector.base_url, 'https://example.com')
|
||||
|
||||
same_connector = books_manager.get_or_create_connector(remote_id)
|
||||
same_connector = connector_manager.get_or_create_connector(remote_id)
|
||||
self.assertEqual(connector.identifier, same_connector.identifier)
|
||||
|
||||
def test_get_connectors(self):
|
||||
''' load all connectors '''
|
||||
remote_id = 'https://example.com/object/1'
|
||||
books_manager.get_or_create_connector(remote_id)
|
||||
connectors = list(books_manager.get_connectors())
|
||||
connector_manager.get_or_create_connector(remote_id)
|
||||
connectors = list(connector_manager.get_connectors())
|
||||
self.assertEqual(len(connectors), 2)
|
||||
self.assertIsInstance(connectors[0], SelfConnector)
|
||||
self.assertIsInstance(connectors[1], BookWyrmConnector)
|
||||
|
||||
def test_search(self):
|
||||
results = books_manager.search('Example')
|
||||
''' search all connectors '''
|
||||
results = connector_manager.search('Example')
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertIsInstance(results[0]['connector'], SelfConnector)
|
||||
self.assertEqual(len(results[0]['results']), 1)
|
||||
self.assertEqual(results[0]['results'][0].title, 'Example Edition')
|
||||
|
||||
def test_local_search(self):
|
||||
results = books_manager.local_search('Example')
|
||||
''' search only the local database '''
|
||||
results = connector_manager.local_search('Example')
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].title, 'Example Edition')
|
||||
|
||||
def test_first_search_result(self):
|
||||
result = books_manager.first_search_result('Example')
|
||||
''' only get one search result '''
|
||||
result = connector_manager.first_search_result('Example')
|
||||
self.assertEqual(result.title, 'Example Edition')
|
||||
no_result = books_manager.first_search_result('dkjfhg')
|
||||
no_result = connector_manager.first_search_result('dkjfhg')
|
||||
self.assertIsNone(no_result)
|
||||
|
||||
def test_load_connector(self):
|
||||
connector = books_manager.load_connector(self.connector)
|
||||
''' load a connector object from the database entry '''
|
||||
connector = connector_manager.load_connector(self.connector)
|
||||
self.assertIsInstance(connector, SelfConnector)
|
||||
self.assertEqual(connector.identifier, 'test_connector')
|
|
@ -1,9 +1,10 @@
|
|||
''' testing book data connectors '''
|
||||
import json
|
||||
import pathlib
|
||||
from dateutil import parser
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
import pytz
|
||||
import responses
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.connectors.openlibrary import Connector
|
||||
|
@ -11,10 +12,13 @@ from bookwyrm.connectors.openlibrary import get_languages, get_description
|
|||
from bookwyrm.connectors.openlibrary import pick_default_edition, \
|
||||
get_openlibrary_key
|
||||
from bookwyrm.connectors.abstract_connector import SearchResult
|
||||
from bookwyrm.connectors.connector_manager import ConnectorException
|
||||
|
||||
|
||||
class Openlibrary(TestCase):
|
||||
''' test loading data from openlibrary.org '''
|
||||
def setUp(self):
|
||||
''' creates the connector we'll use '''
|
||||
models.Connector.objects.create(
|
||||
identifier='openlibrary.org',
|
||||
name='OpenLibrary',
|
||||
|
@ -37,19 +41,85 @@ class Openlibrary(TestCase):
|
|||
self.edition_list_data = json.loads(edition_list_file.read_bytes())
|
||||
|
||||
|
||||
def test_get_remote_id_from_data(self):
|
||||
''' format the remote id from the data '''
|
||||
data = {'key': '/work/OL1234W'}
|
||||
result = self.connector.get_remote_id_from_data(data)
|
||||
self.assertEqual(result, 'https://openlibrary.org/work/OL1234W')
|
||||
# error handlding
|
||||
with self.assertRaises(ConnectorException):
|
||||
self.connector.get_remote_id_from_data({})
|
||||
|
||||
|
||||
def test_is_work_data(self):
|
||||
''' detect if the loaded json is a work '''
|
||||
self.assertEqual(self.connector.is_work_data(self.work_data), True)
|
||||
self.assertEqual(self.connector.is_work_data(self.edition_data), False)
|
||||
|
||||
|
||||
def test_pick_default_edition(self):
|
||||
edition = pick_default_edition(self.edition_list_data['entries'])
|
||||
self.assertEqual(edition['key'], '/books/OL9788823M')
|
||||
@responses.activate
|
||||
def test_get_edition_from_work_data(self):
|
||||
''' loads a list of editions '''
|
||||
data = {'key': '/work/OL1234W'}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/work/OL1234W/editions',
|
||||
json={'entries': []},
|
||||
status=200)
|
||||
with patch('bookwyrm.connectors.openlibrary.pick_default_edition') \
|
||||
as pick_edition:
|
||||
pick_edition.return_value = 'hi'
|
||||
result = self.connector.get_edition_from_work_data(data)
|
||||
self.assertEqual(result, 'hi')
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_get_work_from_edition_data(self):
|
||||
''' loads a list of editions '''
|
||||
data = {'works': [{'key': '/work/OL1234W'}]}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/work/OL1234W',
|
||||
json={'hi': 'there'},
|
||||
status=200)
|
||||
result = self.connector.get_work_from_edition_data(data)
|
||||
self.assertEqual(result, {'hi': 'there'})
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_get_authors_from_data(self):
|
||||
''' find authors in data '''
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/authors/OL382982A',
|
||||
json={'hi': 'there'},
|
||||
status=200)
|
||||
results = self.connector.get_authors_from_data(self.work_data)
|
||||
for result in results:
|
||||
self.assertIsInstance(result, models.Author)
|
||||
|
||||
|
||||
def test_get_cover_url(self):
|
||||
''' formats a url that should contain the cover image '''
|
||||
blob = ['image']
|
||||
result = self.connector.get_cover_url(blob)
|
||||
self.assertEqual(
|
||||
result, 'https://covers.openlibrary.org/b/id/image-L.jpg')
|
||||
|
||||
def test_parse_search_result(self):
|
||||
''' extract the results from the search json response '''
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
'../data/ol_search.json')
|
||||
search_data = json.loads(datafile.read_bytes())
|
||||
result = self.connector.parse_search_data(search_data)
|
||||
self.assertIsInstance(result, list)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
|
||||
def test_format_search_result(self):
|
||||
''' translate json from openlibrary into SearchResult '''
|
||||
datafile = pathlib.Path(__file__).parent.joinpath('../data/ol_search.json')
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
'../data/ol_search.json')
|
||||
search_data = json.loads(datafile.read_bytes())
|
||||
results = self.connector.parse_search_data(search_data)
|
||||
self.assertIsInstance(results, list)
|
||||
|
@ -57,22 +127,66 @@ class Openlibrary(TestCase):
|
|||
result = self.connector.format_search_result(results[0])
|
||||
self.assertIsInstance(result, SearchResult)
|
||||
self.assertEqual(result.title, 'This Is How You Lose the Time War')
|
||||
self.assertEqual(result.key, 'https://openlibrary.org/works/OL20639540W')
|
||||
self.assertEqual(
|
||||
result.key, 'https://openlibrary.org/works/OL20639540W')
|
||||
self.assertEqual(result.author, 'Amal El-Mohtar, Max Gladstone')
|
||||
self.assertEqual(result.year, 2019)
|
||||
self.assertEqual(result.connector, self.connector)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_load_edition_data(self):
|
||||
''' format url from key and make request '''
|
||||
key = 'OL1234W'
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/works/OL1234W/editions',
|
||||
json={'hi': 'there'}
|
||||
)
|
||||
result = self.connector.load_edition_data(key)
|
||||
self.assertEqual(result, {'hi': 'there'})
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_expand_book_data(self):
|
||||
''' given a book, get more editions '''
|
||||
work = models.Work.objects.create(
|
||||
title='Test Work', openlibrary_key='OL1234W')
|
||||
edition = models.Edition.objects.create(
|
||||
title='Test Edition', parent_work=work)
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/works/OL1234W/editions',
|
||||
json={'entries': []},
|
||||
)
|
||||
with patch(
|
||||
'bookwyrm.connectors.abstract_connector.AbstractConnector.' \
|
||||
'create_edition_from_data'):
|
||||
self.connector.expand_book_data(edition)
|
||||
self.connector.expand_book_data(work)
|
||||
|
||||
|
||||
def test_get_description(self):
|
||||
''' should do some cleanup on the description data '''
|
||||
description = get_description(self.work_data['description'])
|
||||
expected = 'First in the Old Kingdom/Abhorsen series.'
|
||||
self.assertEqual(description, expected)
|
||||
|
||||
|
||||
def test_get_openlibrary_key(self):
|
||||
''' extracts the uuid '''
|
||||
key = get_openlibrary_key('/books/OL27320736M')
|
||||
self.assertEqual(key, 'OL27320736M')
|
||||
|
||||
|
||||
def test_get_languages(self):
|
||||
''' looks up languages from a list '''
|
||||
languages = get_languages(self.edition_data['languages'])
|
||||
self.assertEqual(languages, ['English'])
|
||||
|
||||
|
||||
def test_get_ol_key(self):
|
||||
key = get_openlibrary_key('/books/OL27320736M')
|
||||
self.assertEqual(key, 'OL27320736M')
|
||||
def test_pick_default_edition(self):
|
||||
''' detect if the loaded json is an edition '''
|
||||
edition = pick_default_edition(self.edition_list_data['entries'])
|
||||
self.assertEqual(edition['key'], '/books/OL9788823M')
|
||||
|
|
|
@ -9,7 +9,9 @@ from bookwyrm.settings import DOMAIN
|
|||
|
||||
|
||||
class SelfConnector(TestCase):
|
||||
''' just uses local data '''
|
||||
def setUp(self):
|
||||
''' creating the connector '''
|
||||
models.Connector.objects.create(
|
||||
identifier=DOMAIN,
|
||||
name='Local',
|
||||
|
@ -22,58 +24,85 @@ class SelfConnector(TestCase):
|
|||
priority=1,
|
||||
)
|
||||
self.connector = Connector(DOMAIN)
|
||||
self.work = models.Work.objects.create(
|
||||
title='Example Work',
|
||||
)
|
||||
author = models.Author.objects.create(name='Anonymous')
|
||||
self.edition = models.Edition.objects.create(
|
||||
title='Edition of Example Work',
|
||||
published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc),
|
||||
parent_work=self.work,
|
||||
)
|
||||
self.edition.authors.add(author)
|
||||
models.Edition.objects.create(
|
||||
title='Another Edition',
|
||||
parent_work=self.work,
|
||||
series='Anonymous'
|
||||
)
|
||||
models.Edition.objects.create(
|
||||
title='More Editions',
|
||||
subtitle='The Anonymous Edition',
|
||||
parent_work=self.work,
|
||||
)
|
||||
|
||||
edition = models.Edition.objects.create(
|
||||
title='An Edition',
|
||||
parent_work=self.work
|
||||
)
|
||||
edition.authors.add(models.Author.objects.create(name='Fish'))
|
||||
|
||||
|
||||
def test_format_search_result(self):
|
||||
''' create a SearchResult '''
|
||||
author = models.Author.objects.create(name='Anonymous')
|
||||
edition = models.Edition.objects.create(
|
||||
title='Edition of Example Work',
|
||||
published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc),
|
||||
)
|
||||
edition.authors.add(author)
|
||||
result = self.connector.search('Edition of Example')[0]
|
||||
self.assertEqual(result.title, 'Edition of Example Work')
|
||||
self.assertEqual(result.key, self.edition.remote_id)
|
||||
self.assertEqual(result.key, edition.remote_id)
|
||||
self.assertEqual(result.author, 'Anonymous')
|
||||
self.assertEqual(result.year, 1980)
|
||||
self.assertEqual(result.connector, self.connector)
|
||||
|
||||
|
||||
def test_search_rank(self):
|
||||
''' prioritize certain results '''
|
||||
author = models.Author.objects.create(name='Anonymous')
|
||||
edition = models.Edition.objects.create(
|
||||
title='Edition of Example Work',
|
||||
published_date=datetime.datetime(1980, 5, 10, tzinfo=timezone.utc),
|
||||
parent_work=models.Work.objects.create(title='')
|
||||
)
|
||||
# author text is rank C
|
||||
edition.authors.add(author)
|
||||
|
||||
# series is rank D
|
||||
models.Edition.objects.create(
|
||||
title='Another Edition',
|
||||
series='Anonymous',
|
||||
parent_work=models.Work.objects.create(title='')
|
||||
)
|
||||
# subtitle is rank B
|
||||
models.Edition.objects.create(
|
||||
title='More Editions',
|
||||
subtitle='The Anonymous Edition',
|
||||
parent_work=models.Work.objects.create(title='')
|
||||
)
|
||||
# title is rank A
|
||||
models.Edition.objects.create(title='Anonymous')
|
||||
# doesn't rank in this search
|
||||
edition = models.Edition.objects.create(
|
||||
title='An Edition',
|
||||
parent_work=models.Work.objects.create(title='')
|
||||
)
|
||||
|
||||
results = self.connector.search('Anonymous')
|
||||
self.assertEqual(len(results), 2)
|
||||
self.assertEqual(results[0].title, 'More Editions')
|
||||
self.assertEqual(results[1].title, 'Edition of Example Work')
|
||||
self.assertEqual(len(results), 3)
|
||||
self.assertEqual(results[0].title, 'Anonymous')
|
||||
self.assertEqual(results[1].title, 'More Editions')
|
||||
self.assertEqual(results[2].title, 'Edition of Example Work')
|
||||
|
||||
|
||||
def test_search_default_filter(self):
|
||||
def test_search_multiple_editions(self):
|
||||
''' it should get rid of duplicate editions for the same work '''
|
||||
self.work.default_edition = self.edition
|
||||
self.work.save()
|
||||
work = models.Work.objects.create(title='Work Title')
|
||||
edition_1 = models.Edition.objects.create(
|
||||
title='Edition 1 Title', parent_work=work)
|
||||
edition_2 = models.Edition.objects.create(
|
||||
title='Edition 2 Title', parent_work=work)
|
||||
edition_3 = models.Edition.objects.create(
|
||||
title='Fish', parent_work=work)
|
||||
work.default_edition = edition_2
|
||||
work.save()
|
||||
|
||||
results = self.connector.search('Anonymous')
|
||||
# pick the best edition
|
||||
results = self.connector.search('Edition 1 Title')
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].title, 'Edition of Example Work')
|
||||
self.assertEqual(results[0].key, edition_1.remote_id)
|
||||
|
||||
# pick the default edition when no match is best
|
||||
results = self.connector.search('Edition Title')
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].key, edition_2.remote_id)
|
||||
|
||||
# only matches one edition, so no deduplication takes place
|
||||
results = self.connector.search('Fish')
|
||||
self.assertEqual(len(results), 1)
|
||||
self.assertEqual(results[0].title, 'An Edition')
|
||||
self.assertEqual(results[0].key, edition_3.remote_id)
|
||||
|
|
4
bookwyrm/tests/data/goodreads.csv
Normal file
4
bookwyrm/tests/data/goodreads.csv
Normal file
|
@ -0,0 +1,4 @@
|
|||
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,"mixed feelings",,,2,,,0,,,,,
|
|
|
@ -206,3 +206,15 @@ class BaseModel(TestCase):
|
|||
# test subclass match
|
||||
result = models.Status.find_existing_by_remote_id(
|
||||
'https://comment.net')
|
||||
|
||||
|
||||
def test_find_existing(self):
|
||||
''' match a blob of data to a model '''
|
||||
book = models.Edition.objects.create(
|
||||
title='Test edition',
|
||||
openlibrary_key='OL1234',
|
||||
)
|
||||
|
||||
result = models.Edition.find_existing(
|
||||
{'openlibraryKey': 'OL1234'})
|
||||
self.assertEqual(result, book)
|
||||
|
|
|
@ -1,9 +1,16 @@
|
|||
''' testing models '''
|
||||
import datetime
|
||||
import json
|
||||
import pathlib
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
from django.test import TestCase
|
||||
import responses
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from bookwyrm.connectors.abstract_connector import SearchResult
|
||||
|
||||
|
||||
class ImportJob(TestCase):
|
||||
|
@ -55,11 +62,11 @@ class ImportJob(TestCase):
|
|||
'mouse', 'mouse@mouse.mouse', 'mouseword',
|
||||
local=True, localname='mouse')
|
||||
job = models.ImportJob.objects.create(user=user)
|
||||
models.ImportItem.objects.create(
|
||||
self.item_1 = models.ImportItem.objects.create(
|
||||
job=job, index=1, data=currently_reading_data)
|
||||
models.ImportItem.objects.create(
|
||||
self.item_2 = models.ImportItem.objects.create(
|
||||
job=job, index=2, data=read_data)
|
||||
models.ImportItem.objects.create(
|
||||
self.item_3 = models.ImportItem.objects.create(
|
||||
job=job, index=3, data=unknown_read_data)
|
||||
|
||||
|
||||
|
@ -73,8 +80,7 @@ class ImportJob(TestCase):
|
|||
def test_shelf(self):
|
||||
''' converts to the local shelf typology '''
|
||||
expected = 'reading'
|
||||
item = models.ImportItem.objects.get(index=1)
|
||||
self.assertEqual(item.shelf, expected)
|
||||
self.assertEqual(self.item_1.shelf, expected)
|
||||
|
||||
|
||||
def test_date_added(self):
|
||||
|
@ -92,21 +98,79 @@ class ImportJob(TestCase):
|
|||
|
||||
|
||||
def test_currently_reading_reads(self):
|
||||
''' infer currently reading dates where available '''
|
||||
expected = [models.ReadThrough(
|
||||
start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))]
|
||||
start_date=datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc)
|
||||
)]
|
||||
actual = models.ImportItem.objects.get(index=1)
|
||||
self.assertEqual(actual.reads[0].start_date, expected[0].start_date)
|
||||
self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date)
|
||||
|
||||
def test_read_reads(self):
|
||||
actual = models.ImportItem.objects.get(index=2)
|
||||
self.assertEqual(actual.reads[0].start_date, datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))
|
||||
self.assertEqual(actual.reads[0].finish_date, datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc))
|
||||
''' infer read dates where available '''
|
||||
actual = self.item_2
|
||||
self.assertEqual(
|
||||
actual.reads[0].start_date,
|
||||
datetime.datetime(2019, 4, 9, 0, 0, tzinfo=timezone.utc))
|
||||
self.assertEqual(
|
||||
actual.reads[0].finish_date,
|
||||
datetime.datetime(2019, 4, 12, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
def test_unread_reads(self):
|
||||
''' handle books with no read dates '''
|
||||
expected = []
|
||||
actual = models.ImportItem.objects.get(index=3)
|
||||
self.assertEqual(actual.reads, expected)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_get_book_from_isbn(self):
|
||||
''' search and load books by isbn (9780356506999) '''
|
||||
connector_info = models.Connector.objects.create(
|
||||
identifier='openlibrary.org',
|
||||
name='OpenLibrary',
|
||||
connector_file='openlibrary',
|
||||
base_url='https://openlibrary.org',
|
||||
books_url='https://openlibrary.org',
|
||||
covers_url='https://covers.openlibrary.org',
|
||||
search_url='https://openlibrary.org/search?q=',
|
||||
priority=3,
|
||||
)
|
||||
connector = connector_manager.load_connector(connector_info)
|
||||
result = SearchResult(
|
||||
title='Test Result',
|
||||
key='https://openlibrary.org/works/OL1234W',
|
||||
author='An Author',
|
||||
year='1980',
|
||||
connector=connector,
|
||||
)
|
||||
|
||||
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
'../data/ol_edition.json')
|
||||
bookdata = json.loads(datafile.read_bytes())
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/works/OL1234W',
|
||||
json=bookdata,
|
||||
status=200)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/works/OL15832982W',
|
||||
json=bookdata,
|
||||
status=200)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
'https://openlibrary.org/authors/OL382982A',
|
||||
json={'name': 'test author'},
|
||||
status=200)
|
||||
|
||||
with patch(
|
||||
'bookwyrm.connectors.abstract_connector.load_more_data.delay'):
|
||||
with patch(
|
||||
'bookwyrm.connectors.connector_manager.first_search_result'
|
||||
) as search:
|
||||
search.return_value = result
|
||||
book = self.item_1.get_book_from_isbn()
|
||||
|
||||
self.assertEqual(book.title, 'Sabriel')
|
||||
|
|
104
bookwyrm/tests/test_goodreads_import.py
Normal file
104
bookwyrm/tests/test_goodreads_import.py
Normal file
|
@ -0,0 +1,104 @@
|
|||
''' testing import '''
|
||||
from collections import namedtuple
|
||||
import pathlib
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
import responses
|
||||
|
||||
from bookwyrm import goodreads_import, models
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
class GoodreadsImport(TestCase):
|
||||
''' importing from goodreads csv '''
|
||||
def setUp(self):
|
||||
''' use a test csv '''
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
'data/goodreads.csv')
|
||||
self.csv = open(datafile, 'r')
|
||||
self.user = models.User.objects.create_user(
|
||||
'mouse', 'mouse@mouse.mouse', 'password', local=True)
|
||||
|
||||
models.Connector.objects.create(
|
||||
identifier=DOMAIN,
|
||||
name='Local',
|
||||
local=True,
|
||||
connector_file='self_connector',
|
||||
base_url='https://%s' % DOMAIN,
|
||||
books_url='https://%s/book' % DOMAIN,
|
||||
covers_url='https://%s/images/covers' % DOMAIN,
|
||||
search_url='https://%s/search?q=' % DOMAIN,
|
||||
priority=1,
|
||||
)
|
||||
|
||||
|
||||
def test_create_job(self):
|
||||
''' creates the import job entry and checks csv '''
|
||||
import_job = goodreads_import.create_job(
|
||||
self.user, self.csv, False, 'public')
|
||||
self.assertEqual(import_job.user, self.user)
|
||||
self.assertEqual(import_job.include_reviews, False)
|
||||
self.assertEqual(import_job.privacy, 'public')
|
||||
|
||||
import_items = models.ImportItem.objects.filter(job=import_job).all()
|
||||
self.assertEqual(len(import_items), 3)
|
||||
self.assertEqual(import_items[0].index, 0)
|
||||
self.assertEqual(import_items[0].data['Book Id'], '42036538')
|
||||
self.assertEqual(import_items[1].index, 1)
|
||||
self.assertEqual(import_items[1].data['Book Id'], '52691223')
|
||||
self.assertEqual(import_items[2].index, 2)
|
||||
self.assertEqual(import_items[2].data['Book Id'], '28694510')
|
||||
|
||||
|
||||
def test_create_retry_job(self):
|
||||
''' trying again with items that didn't import '''
|
||||
import_job = goodreads_import.create_job(
|
||||
self.user, self.csv, False, 'unlisted')
|
||||
import_items = models.ImportItem.objects.filter(
|
||||
job=import_job
|
||||
).all()[:2]
|
||||
|
||||
retry = goodreads_import.create_retry_job(
|
||||
self.user, import_job, import_items)
|
||||
self.assertNotEqual(import_job, retry)
|
||||
self.assertEqual(retry.user, self.user)
|
||||
self.assertEqual(retry.include_reviews, False)
|
||||
self.assertEqual(retry.privacy, 'unlisted')
|
||||
|
||||
retry_items = models.ImportItem.objects.filter(job=retry).all()
|
||||
self.assertEqual(len(retry_items), 2)
|
||||
self.assertEqual(retry_items[0].index, 0)
|
||||
self.assertEqual(retry_items[0].data['Book Id'], '42036538')
|
||||
self.assertEqual(retry_items[1].index, 1)
|
||||
self.assertEqual(retry_items[1].data['Book Id'], '52691223')
|
||||
|
||||
|
||||
def test_start_import(self):
|
||||
''' begin loading books '''
|
||||
import_job = goodreads_import.create_job(
|
||||
self.user, self.csv, False, 'unlisted')
|
||||
MockTask = namedtuple('Task', ('id'))
|
||||
mock_task = MockTask(7)
|
||||
with patch('bookwyrm.goodreads_import.import_data.delay') as start:
|
||||
start.return_value = mock_task
|
||||
goodreads_import.start_import(import_job)
|
||||
import_job.refresh_from_db()
|
||||
self.assertEqual(import_job.task_id, '7')
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_import_data(self):
|
||||
''' resolve entry '''
|
||||
import_job = goodreads_import.create_job(
|
||||
self.user, self.csv, False, 'unlisted')
|
||||
book = models.Edition.objects.create(title='Test Book')
|
||||
|
||||
with patch(
|
||||
'bookwyrm.models.import_job.ImportItem.get_book_from_isbn'
|
||||
) as resolve:
|
||||
resolve.return_value = book
|
||||
with patch('bookwyrm.outgoing.handle_imported_book'):
|
||||
goodreads_import.import_data(import_job.id)
|
||||
|
||||
import_item = models.ImportItem.objects.get(job=import_job, index=0)
|
||||
self.assertEqual(import_item.book.id, book.id)
|
|
@ -273,6 +273,12 @@ class Incoming(TestCase):
|
|||
incoming.handle_create(activity)
|
||||
self.assertEqual(models.Status.objects.count(), 2)
|
||||
|
||||
def test_handle_create_unknown_type(self):
|
||||
''' folks send you all kinds of things '''
|
||||
activity = {'object': {'id': 'hi'}, 'type': 'Fish'}
|
||||
result = incoming.handle_create(activity)
|
||||
self.assertIsNone(result)
|
||||
|
||||
def test_handle_create_remote_note_with_mention(self):
|
||||
''' should only create it under the right circumstances '''
|
||||
self.assertEqual(models.Status.objects.count(), 1)
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
''' sending out activities '''
|
||||
import csv
|
||||
import json
|
||||
import pathlib
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
import responses
|
||||
|
||||
from bookwyrm import models, outgoing
|
||||
from bookwyrm import forms, models, outgoing
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class Outgoing(TestCase):
|
||||
''' sends out activities '''
|
||||
def setUp(self):
|
||||
''' we'll need some data '''
|
||||
self.factory = RequestFactory()
|
||||
with patch('bookwyrm.models.user.set_remote_server'):
|
||||
self.remote_user = models.User.objects.create_user(
|
||||
'rat', 'rat@rat.com', 'ratword',
|
||||
|
@ -47,6 +52,67 @@ class Outgoing(TestCase):
|
|||
)
|
||||
|
||||
|
||||
def test_outbox(self):
|
||||
''' returns user's statuses '''
|
||||
request = self.factory.get('')
|
||||
result = outgoing.outbox(request, 'mouse')
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
|
||||
def test_outbox_bad_method(self):
|
||||
''' can't POST to outbox '''
|
||||
request = self.factory.post('')
|
||||
result = outgoing.outbox(request, 'mouse')
|
||||
self.assertEqual(result.status_code, 405)
|
||||
|
||||
def test_outbox_unknown_user(self):
|
||||
''' should 404 for unknown and remote users '''
|
||||
request = self.factory.post('')
|
||||
result = outgoing.outbox(request, 'beepboop')
|
||||
self.assertEqual(result.status_code, 405)
|
||||
result = outgoing.outbox(request, 'rat')
|
||||
self.assertEqual(result.status_code, 405)
|
||||
|
||||
def test_outbox_privacy(self):
|
||||
''' don't show dms et cetera in outbox '''
|
||||
models.Status.objects.create(
|
||||
content='PRIVATE!!', user=self.local_user, privacy='direct')
|
||||
models.Status.objects.create(
|
||||
content='bffs ONLY', user=self.local_user, privacy='followers')
|
||||
models.Status.objects.create(
|
||||
content='unlisted status', user=self.local_user, privacy='unlisted')
|
||||
models.Status.objects.create(
|
||||
content='look at this', user=self.local_user, privacy='public')
|
||||
|
||||
request = self.factory.get('')
|
||||
result = outgoing.outbox(request, 'mouse')
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
data = json.loads(result.content)
|
||||
self.assertEqual(data['type'], 'OrderedCollection')
|
||||
self.assertEqual(data['totalItems'], 2)
|
||||
|
||||
def test_outbox_filter(self):
|
||||
''' if we only care about reviews, only get reviews '''
|
||||
models.Review.objects.create(
|
||||
content='look at this', name='hi', rating=1,
|
||||
book=self.book, user=self.local_user)
|
||||
models.Status.objects.create(
|
||||
content='look at this', user=self.local_user)
|
||||
|
||||
request = self.factory.get('', {'type': 'bleh'})
|
||||
result = outgoing.outbox(request, 'mouse')
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
data = json.loads(result.content)
|
||||
self.assertEqual(data['type'], 'OrderedCollection')
|
||||
self.assertEqual(data['totalItems'], 2)
|
||||
|
||||
request = self.factory.get('', {'type': 'Review'})
|
||||
result = outgoing.outbox(request, 'mouse')
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
data = json.loads(result.content)
|
||||
self.assertEqual(data['type'], 'OrderedCollection')
|
||||
self.assertEqual(data['totalItems'], 1)
|
||||
|
||||
|
||||
def test_handle_follow(self):
|
||||
''' send a follow request '''
|
||||
self.assertEqual(models.UserFollowRequest.objects.count(), 0)
|
||||
|
@ -192,3 +258,190 @@ class Outgoing(TestCase):
|
|||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_unshelve(self.local_user, self.book, self.shelf)
|
||||
self.assertEqual(self.shelf.books.count(), 0)
|
||||
|
||||
|
||||
def test_handle_imported_book(self):
|
||||
''' goodreads import added a book, this adds related connections '''
|
||||
shelf = self.local_user.shelf_set.filter(identifier='read').first()
|
||||
self.assertIsNone(shelf.books.first())
|
||||
|
||||
import_job = models.ImportJob.objects.create(user=self.local_user)
|
||||
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
|
||||
csv_file = open(datafile, 'r')
|
||||
for index, entry in enumerate(list(csv.DictReader(csv_file))):
|
||||
import_item = models.ImportItem.objects.create(
|
||||
job_id=import_job.id, index=index, data=entry, book=self.book)
|
||||
break
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_imported_book(
|
||||
self.local_user, import_item, False, 'public')
|
||||
|
||||
shelf.refresh_from_db()
|
||||
self.assertEqual(shelf.books.first(), self.book)
|
||||
|
||||
readthrough = models.ReadThrough.objects.get(user=self.local_user)
|
||||
self.assertEqual(readthrough.book, self.book)
|
||||
# I can't remember how to create dates and I don't want to look it up.
|
||||
self.assertEqual(readthrough.start_date.year, 2020)
|
||||
self.assertEqual(readthrough.start_date.month, 10)
|
||||
self.assertEqual(readthrough.start_date.day, 21)
|
||||
self.assertEqual(readthrough.finish_date.year, 2020)
|
||||
self.assertEqual(readthrough.finish_date.month, 10)
|
||||
self.assertEqual(readthrough.finish_date.day, 25)
|
||||
|
||||
|
||||
def test_handle_imported_book_already_shelved(self):
|
||||
''' goodreads import added a book, this adds related connections '''
|
||||
shelf = self.local_user.shelf_set.filter(identifier='to-read').first()
|
||||
models.ShelfBook.objects.create(
|
||||
shelf=shelf, added_by=self.local_user, book=self.book)
|
||||
|
||||
import_job = models.ImportJob.objects.create(user=self.local_user)
|
||||
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
|
||||
csv_file = open(datafile, 'r')
|
||||
for index, entry in enumerate(list(csv.DictReader(csv_file))):
|
||||
import_item = models.ImportItem.objects.create(
|
||||
job_id=import_job.id, index=index, data=entry, book=self.book)
|
||||
break
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_imported_book(
|
||||
self.local_user, import_item, False, 'public')
|
||||
|
||||
shelf.refresh_from_db()
|
||||
self.assertEqual(shelf.books.first(), self.book)
|
||||
self.assertIsNone(
|
||||
self.local_user.shelf_set.get(identifier='read').books.first())
|
||||
readthrough = models.ReadThrough.objects.get(user=self.local_user)
|
||||
self.assertEqual(readthrough.book, self.book)
|
||||
self.assertEqual(readthrough.start_date.year, 2020)
|
||||
self.assertEqual(readthrough.start_date.month, 10)
|
||||
self.assertEqual(readthrough.start_date.day, 21)
|
||||
self.assertEqual(readthrough.finish_date.year, 2020)
|
||||
self.assertEqual(readthrough.finish_date.month, 10)
|
||||
self.assertEqual(readthrough.finish_date.day, 25)
|
||||
|
||||
|
||||
def test_handle_imported_book_review(self):
|
||||
''' goodreads review import '''
|
||||
import_job = models.ImportJob.objects.create(user=self.local_user)
|
||||
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
|
||||
csv_file = open(datafile, 'r')
|
||||
entry = list(csv.DictReader(csv_file))[2]
|
||||
import_item = models.ImportItem.objects.create(
|
||||
job_id=import_job.id, index=0, data=entry, book=self.book)
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_imported_book(
|
||||
self.local_user, import_item, True, 'unlisted')
|
||||
review = models.Review.objects.get(book=self.book, user=self.local_user)
|
||||
self.assertEqual(review.content, 'mixed feelings')
|
||||
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.local_user)
|
||||
datafile = pathlib.Path(__file__).parent.joinpath('data/goodreads.csv')
|
||||
csv_file = open(datafile, 'r')
|
||||
entry = list(csv.DictReader(csv_file))[2]
|
||||
import_item = models.ImportItem.objects.create(
|
||||
job_id=import_job.id, index=0, data=entry, book=self.book)
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_imported_book(
|
||||
self.local_user, import_item, False, 'unlisted')
|
||||
self.assertFalse(models.Review.objects.filter(
|
||||
book=self.book, user=self.local_user
|
||||
).exists())
|
||||
|
||||
|
||||
def test_handle_status(self):
|
||||
''' create a status '''
|
||||
form = forms.CommentForm({
|
||||
'content': 'hi',
|
||||
'user': self.local_user.id,
|
||||
'book': self.book.id,
|
||||
'privacy': 'public',
|
||||
})
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_status(self.local_user, form)
|
||||
status = models.Comment.objects.get()
|
||||
self.assertEqual(status.content, '<p>hi</p>')
|
||||
self.assertEqual(status.user, self.local_user)
|
||||
self.assertEqual(status.book, self.book)
|
||||
|
||||
def test_handle_status_reply(self):
|
||||
''' create a status in reply to an existing status '''
|
||||
user = models.User.objects.create_user(
|
||||
'rat', 'rat@rat.com', 'password', local=True)
|
||||
parent = models.Status.objects.create(
|
||||
content='parent status', user=self.local_user)
|
||||
form = forms.ReplyForm({
|
||||
'content': 'hi',
|
||||
'user': user.id,
|
||||
'reply_parent': parent.id,
|
||||
'privacy': 'public',
|
||||
})
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_status(user, form)
|
||||
status = models.Status.objects.get(user=user)
|
||||
self.assertEqual(status.content, '<p>hi</p>')
|
||||
self.assertEqual(status.user, user)
|
||||
self.assertEqual(
|
||||
models.Notification.objects.get().user, self.local_user)
|
||||
|
||||
def test_handle_status_mentions(self):
|
||||
''' @mention a user in a post '''
|
||||
user = models.User.objects.create_user(
|
||||
'rat', 'rat@rat.com', 'password', local=True)
|
||||
form = forms.CommentForm({
|
||||
'content': 'hi @rat',
|
||||
'user': self.local_user.id,
|
||||
'book': self.book.id,
|
||||
'privacy': 'public',
|
||||
})
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_status(self.local_user, form)
|
||||
status = models.Status.objects.get()
|
||||
self.assertEqual(
|
||||
status.content,
|
||||
'<p>hi <a href="%s">@rat</a></p>' % user.remote_id)
|
||||
self.assertEqual(list(status.mention_users.all()), [user])
|
||||
self.assertEqual(models.Notification.objects.get().user, user)
|
||||
|
||||
def test_handle_status_reply_with_mentions(self):
|
||||
''' reply to a post with an @mention'ed user '''
|
||||
user = models.User.objects.create_user(
|
||||
'rat', 'rat@rat.com', 'password', local=True)
|
||||
form = forms.CommentForm({
|
||||
'content': 'hi @rat@example.com',
|
||||
'user': self.local_user.id,
|
||||
'book': self.book.id,
|
||||
'privacy': 'public',
|
||||
})
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_status(self.local_user, form)
|
||||
status = models.Status.objects.get()
|
||||
|
||||
form = forms.ReplyForm({
|
||||
'content': 'right',
|
||||
'user': user,
|
||||
'privacy': 'public',
|
||||
'reply_parent': status.id
|
||||
})
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
outgoing.handle_status(user, form)
|
||||
|
||||
reply = models.Status.replies(status).first()
|
||||
self.assertEqual(reply.content, '<p>right</p>')
|
||||
self.assertEqual(reply.user, user)
|
||||
self.assertTrue(self.remote_user in reply.mention_users.all())
|
||||
self.assertTrue(self.local_user in reply.mention_users.all())
|
||||
|
|
|
@ -42,6 +42,9 @@ class ViewActions(TestCase):
|
|||
content='Test status',
|
||||
remote_id='https://example.com/status/1',
|
||||
)
|
||||
self.work = models.Work.objects.create(title='Test Work')
|
||||
self.book = models.Edition.objects.create(
|
||||
title='Test Book', parent_work=self.work)
|
||||
self.settings = models.SiteSettings.objects.create(id=1)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
|
@ -352,3 +355,43 @@ class ViewActions(TestCase):
|
|||
author.refresh_from_db()
|
||||
self.assertEqual(author.name, 'Test Author')
|
||||
self.assertEqual(resp.template_name, 'edit_author.html')
|
||||
|
||||
|
||||
def test_tag(self):
|
||||
''' add a tag to a book '''
|
||||
request = self.factory.post(
|
||||
'', {
|
||||
'name': 'A Tag!?',
|
||||
'book': self.book.id,
|
||||
})
|
||||
request.user = self.local_user
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
actions.tag(request)
|
||||
|
||||
tag = models.Tag.objects.get()
|
||||
user_tag = models.UserTag.objects.get()
|
||||
self.assertEqual(tag.name, 'A Tag!?')
|
||||
self.assertEqual(tag.identifier, 'A+Tag%21%3F')
|
||||
self.assertEqual(user_tag.user, self.local_user)
|
||||
self.assertEqual(user_tag.book, self.book)
|
||||
|
||||
|
||||
def test_untag(self):
|
||||
''' remove a tag from a book '''
|
||||
tag = models.Tag.objects.create(name='A Tag!?')
|
||||
user_tag = models.UserTag.objects.create(
|
||||
user=self.local_user, book=self.book, tag=tag)
|
||||
request = self.factory.post(
|
||||
'', {
|
||||
'user': self.local_user.id,
|
||||
'book': self.book.id,
|
||||
'name': tag.name,
|
||||
})
|
||||
request.user = self.local_user
|
||||
|
||||
with patch('bookwyrm.broadcast.broadcast_task.delay'):
|
||||
actions.untag(request)
|
||||
|
||||
self.assertTrue(models.Tag.objects.filter(name='A Tag!?').exists())
|
||||
self.assertFalse(models.UserTag.objects.exists())
|
||||
|
|
567
bookwyrm/tests/test_views.py
Normal file
567
bookwyrm/tests/test_views.py
Normal file
|
@ -0,0 +1,567 @@
|
|||
''' test for app action functionality '''
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import JsonResponse
|
||||
from django.template.response import TemplateResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from bookwyrm import models, views
|
||||
from bookwyrm.connectors import abstract_connector
|
||||
from bookwyrm.settings import DOMAIN, USER_AGENT
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class Views(TestCase):
|
||||
''' every response to a get request, html or json '''
|
||||
def setUp(self):
|
||||
''' we need basic test data and mocks '''
|
||||
self.factory = RequestFactory()
|
||||
self.work = models.Work.objects.create(title='Test Work')
|
||||
self.book = models.Edition.objects.create(
|
||||
title='Test Book', parent_work=self.work)
|
||||
models.Connector.objects.create(
|
||||
identifier='self',
|
||||
connector_file='self_connector',
|
||||
local=True
|
||||
)
|
||||
self.local_user = models.User.objects.create_user(
|
||||
'mouse', 'mouse@mouse.mouse', 'password', local=True)
|
||||
with patch('bookwyrm.models.user.set_remote_server.delay'):
|
||||
self.remote_user = models.User.objects.create_user(
|
||||
'rat', 'rat@rat.com', 'ratword',
|
||||
local=False,
|
||||
remote_id='https://example.com/users/rat',
|
||||
inbox='https://example.com/users/rat/inbox',
|
||||
outbox='https://example.com/users/rat/outbox',
|
||||
)
|
||||
|
||||
|
||||
def test_get_edition(self):
|
||||
''' given an edition or a work, returns an edition '''
|
||||
self.assertEqual(
|
||||
views.get_edition(self.book.id), self.book)
|
||||
self.assertEqual(
|
||||
views.get_edition(self.work.id), self.book)
|
||||
|
||||
|
||||
def test_get_user_from_username(self):
|
||||
''' works for either localname or username '''
|
||||
self.assertEqual(
|
||||
views.get_user_from_username('mouse'), self.local_user)
|
||||
self.assertEqual(
|
||||
views.get_user_from_username('mouse@%s' % DOMAIN), self.local_user)
|
||||
with self.assertRaises(models.User.DoesNotExist):
|
||||
views.get_user_from_username('mojfse@example.com')
|
||||
|
||||
|
||||
def test_is_api_request(self):
|
||||
''' should it return html or json '''
|
||||
request = self.factory.get('/path')
|
||||
request.headers = {'Accept': 'application/json'}
|
||||
self.assertTrue(views.is_api_request(request))
|
||||
|
||||
request = self.factory.get('/path.json')
|
||||
request.headers = {'Accept': 'Praise'}
|
||||
self.assertTrue(views.is_api_request(request))
|
||||
|
||||
request = self.factory.get('/path')
|
||||
request.headers = {'Accept': 'Praise'}
|
||||
self.assertFalse(views.is_api_request(request))
|
||||
|
||||
|
||||
def test_home_tab(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
result = views.home_tab(request, 'local')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'feed.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_direct_messages_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
result = views.direct_messages_page(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'direct_messages.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_get_activity_feed(self):
|
||||
''' loads statuses '''
|
||||
rat = models.User.objects.create_user(
|
||||
'rat', 'rat@rat.rat', 'password', local=True)
|
||||
|
||||
public_status = models.Comment.objects.create(
|
||||
content='public status', book=self.book, user=self.local_user)
|
||||
direct_status = models.Status.objects.create(
|
||||
content='direct', user=self.local_user, privacy='direct')
|
||||
|
||||
rat_public = models.Status.objects.create(
|
||||
content='blah blah', user=rat)
|
||||
rat_unlisted = models.Status.objects.create(
|
||||
content='blah blah', user=rat, privacy='unlisted')
|
||||
remote_status = models.Status.objects.create(
|
||||
content='blah blah', user=self.remote_user)
|
||||
followers_status = models.Status.objects.create(
|
||||
content='blah', user=rat, privacy='followers')
|
||||
rat_mention = models.Status.objects.create(
|
||||
content='blah blah blah', user=rat, privacy='followers')
|
||||
rat_mention.mention_users.set([self.local_user])
|
||||
|
||||
statuses = views.get_activity_feed(self.local_user, 'home')
|
||||
self.assertEqual(len(statuses), 2)
|
||||
self.assertEqual(statuses[1], public_status)
|
||||
self.assertEqual(statuses[0], rat_mention)
|
||||
|
||||
statuses = views.get_activity_feed(
|
||||
self.local_user, 'home', model=models.Comment)
|
||||
self.assertEqual(len(statuses), 1)
|
||||
self.assertEqual(statuses[0], public_status)
|
||||
|
||||
statuses = views.get_activity_feed(self.local_user, 'local')
|
||||
self.assertEqual(len(statuses), 2)
|
||||
self.assertEqual(statuses[1], public_status)
|
||||
self.assertEqual(statuses[0], rat_public)
|
||||
|
||||
statuses = views.get_activity_feed(self.local_user, 'direct')
|
||||
self.assertEqual(len(statuses), 1)
|
||||
self.assertEqual(statuses[0], direct_status)
|
||||
|
||||
statuses = views.get_activity_feed(self.local_user, 'federated')
|
||||
self.assertEqual(len(statuses), 3)
|
||||
self.assertEqual(statuses[2], public_status)
|
||||
self.assertEqual(statuses[1], rat_public)
|
||||
self.assertEqual(statuses[0], remote_status)
|
||||
|
||||
statuses = views.get_activity_feed(self.local_user, 'friends')
|
||||
self.assertEqual(len(statuses), 2)
|
||||
self.assertEqual(statuses[1], public_status)
|
||||
self.assertEqual(statuses[0], rat_mention)
|
||||
|
||||
rat.followers.add(self.local_user)
|
||||
statuses = views.get_activity_feed(self.local_user, 'friends')
|
||||
self.assertEqual(len(statuses), 5)
|
||||
self.assertEqual(statuses[4], public_status)
|
||||
self.assertEqual(statuses[3], rat_public)
|
||||
self.assertEqual(statuses[2], rat_unlisted)
|
||||
self.assertEqual(statuses[1], followers_status)
|
||||
self.assertEqual(statuses[0], rat_mention)
|
||||
|
||||
|
||||
def test_search_json_response(self):
|
||||
''' searches local data only and returns book data in json format '''
|
||||
# we need a connector for this, sorry
|
||||
request = self.factory.get('', {'q': 'Test Book'})
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
response = views.search(request)
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]['title'], 'Test Book')
|
||||
self.assertEqual(
|
||||
data[0]['key'], 'https://%s/book/%d' % (DOMAIN, self.book.id))
|
||||
|
||||
|
||||
def test_search_html_response(self):
|
||||
''' searches remote connectors '''
|
||||
class TestConnector(abstract_connector.AbstractMinimalConnector):
|
||||
''' nothing added here '''
|
||||
def format_search_result(self, search_result):
|
||||
pass
|
||||
def get_or_create_book(self, remote_id):
|
||||
pass
|
||||
def parse_search_data(self, data):
|
||||
pass
|
||||
models.Connector.objects.create(
|
||||
identifier='example.com',
|
||||
connector_file='openlibrary',
|
||||
base_url='https://example.com',
|
||||
books_url='https://example.com/books',
|
||||
covers_url='https://example.com/covers',
|
||||
search_url='https://example.com/search?q=',
|
||||
)
|
||||
connector = TestConnector('example.com')
|
||||
|
||||
search_result = abstract_connector.SearchResult(
|
||||
key='http://www.example.com/book/1',
|
||||
title='Gideon the Ninth',
|
||||
author='Tamsyn Muir',
|
||||
year='2019',
|
||||
connector=connector
|
||||
)
|
||||
|
||||
request = self.factory.get('', {'q': 'Test Book'})
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
with patch(
|
||||
'bookwyrm.connectors.connector_manager.search') as manager:
|
||||
manager.return_value = [search_result]
|
||||
response = views.search(request)
|
||||
self.assertIsInstance(response, TemplateResponse)
|
||||
self.assertEqual(response.template_name, 'search_results.html')
|
||||
self.assertEqual(
|
||||
response.context_data['book_results'][0].title, 'Gideon the Ninth')
|
||||
|
||||
|
||||
def test_search_html_response_users(self):
|
||||
''' searches remote connectors '''
|
||||
request = self.factory.get('', {'q': 'mouse'})
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
with patch('bookwyrm.connectors.connector_manager.search'):
|
||||
response = views.search(request)
|
||||
self.assertIsInstance(response, TemplateResponse)
|
||||
self.assertEqual(response.template_name, 'search_results.html')
|
||||
self.assertEqual(
|
||||
response.context_data['user_results'][0], self.local_user)
|
||||
|
||||
|
||||
def test_import_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
result = views.import_page(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'import.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_import_status(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
import_job = models.ImportJob.objects.create(user=self.local_user)
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.tasks.app.AsyncResult') as async_result:
|
||||
async_result.return_value = []
|
||||
result = views.import_status(request, import_job.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'import_status.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_login_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = AnonymousUser
|
||||
result = views.login_page(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'login.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request.user = self.local_user
|
||||
result = views.login_page(request)
|
||||
self.assertEqual(result.url, '/')
|
||||
self.assertEqual(result.status_code, 302)
|
||||
|
||||
|
||||
def test_about_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
result = views.about_page(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'about.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_password_reset_request(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
result = views.password_reset_request(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'password_reset_request.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_password_reset(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
code = models.PasswordReset.objects.create(user=self.local_user)
|
||||
request = self.factory.get('')
|
||||
request.user = AnonymousUser
|
||||
result = views.password_reset(request, code.code)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'password_reset.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_invite_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
models.SiteInvite.objects.create(code='hi', user=self.local_user)
|
||||
request = self.factory.get('')
|
||||
request.user = AnonymousUser
|
||||
# why?? this is annoying.
|
||||
request.user.is_authenticated = False
|
||||
with patch('bookwyrm.models.site.SiteInvite.valid') as invite:
|
||||
invite.return_value = True
|
||||
result = views.invite_page(request, 'hi')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'invite.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_manage_invites(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
request.user.is_superuser = True
|
||||
result = views.manage_invites(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'manage_invites.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_notifications_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
result = views.notifications_page(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'notifications.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_user_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.user_page(request, 'mouse')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'user.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.user_page(request, 'mouse')
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_followers_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.followers_page(request, 'mouse')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'followers.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.followers_page(request, 'mouse')
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_following_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.following_page(request, 'mouse')
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'following.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.following_page(request, 'mouse')
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_status_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
status = models.Status.objects.create(
|
||||
content='hi', user=self.local_user)
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.status_page(request, 'mouse', status.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'status.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.status_page(request, 'mouse', status.id)
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_replies_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
status = models.Status.objects.create(
|
||||
content='hi', user=self.local_user)
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.replies_page(request, 'mouse', status.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'status.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.replies_page(request, 'mouse', status.id)
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_edit_profile_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
result = views.edit_profile_page(request)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'edit_user.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_book_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.book_page(request, self.book.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'book.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.book_page(request, self.book.id)
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_edit_book_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
request.user.is_superuser = True
|
||||
result = views.edit_book_page(request, self.book.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'edit_book.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_edit_author_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
author = models.Author.objects.create(name='Test Author')
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
request.user.is_superuser = True
|
||||
result = views.edit_author_page(request, author.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'edit_author.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_editions_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.editions_page(request, self.work.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'editions.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.editions_page(request, self.work.id)
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_author_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
author = models.Author.objects.create(name='Jessica')
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.author_page(request, author.id)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'author.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.author_page(request, author.id)
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_tag_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
tag = models.Tag.objects.create(name='hi there')
|
||||
models.UserTag.objects.create(
|
||||
tag=tag, user=self.local_user, book=self.book)
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.tag_page(request, tag.identifier)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'tag.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.tag_page(request, tag.identifier)
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_shelf_page(self):
|
||||
''' there are so many views, this just makes sure it LOADS '''
|
||||
shelf = self.local_user.shelf_set.first()
|
||||
request = self.factory.get('')
|
||||
request.user = self.local_user
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = False
|
||||
result = views.shelf_page(
|
||||
request, self.local_user.username, shelf.identifier)
|
||||
self.assertIsInstance(result, TemplateResponse)
|
||||
self.assertEqual(result.template_name, 'shelf.html')
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
with patch('bookwyrm.views.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
result = views.shelf_page(
|
||||
request, self.local_user.username, shelf.identifier)
|
||||
self.assertIsInstance(result, JsonResponse)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
|
||||
def test_is_bookwyrm_request(self):
|
||||
''' tests the function that checks if a request came from a bookwyrm instance '''
|
||||
request = self.factory.get('', {'q': 'Test Book'})
|
||||
self.assertFalse(views.is_bookworm_request(request))
|
||||
|
||||
request = self.factory.get('', {'q': 'Test Book'},
|
||||
HTTP_USER_AGENT="http.rb/4.4.1 (Mastodon/3.3.0; +https://mastodon.social/)")
|
||||
self.assertFalse(views.is_bookworm_request(request))
|
||||
|
||||
request = self.factory.get('', {'q': 'Test Book'}, HTTP_USER_AGENT=USER_AGENT)
|
||||
self.assertTrue(views.is_bookworm_request(request))
|
|
@ -5,11 +5,10 @@ from django.urls import path, re_path
|
|||
|
||||
from bookwyrm import incoming, outgoing, views, settings, wellknown
|
||||
from bookwyrm import view_actions as actions
|
||||
from bookwyrm.utils import regex
|
||||
|
||||
username_regex = r'(?P<username>[\w\-_\.]+@[\w\-\_\.]+)'
|
||||
localname_regex = r'(?P<username>[\w\-_\.]+)'
|
||||
user_path = r'^user/%s' % username_regex
|
||||
local_user_path = r'^user/%s' % localname_regex
|
||||
user_path = r'^user/(?P<username>%s)' % regex.username
|
||||
local_user_path = r'^user/(?P<username>%s)' % regex.localname
|
||||
|
||||
status_types = [
|
||||
'status',
|
||||
|
@ -20,7 +19,7 @@ status_types = [
|
|||
'generatednote'
|
||||
]
|
||||
status_path = r'%s/(%s)/(?P<status_id>\d+)' % \
|
||||
(local_user_path, '|'.join(status_types))
|
||||
(user_path, '|'.join(status_types))
|
||||
|
||||
book_path = r'^book/(?P<book_id>\d+)'
|
||||
|
||||
|
@ -53,6 +52,7 @@ urlpatterns = [
|
|||
|
||||
path('', views.home),
|
||||
re_path(r'^(?P<tab>home|local|federated)/?$', views.home_tab),
|
||||
re_path(r'^discover/?$', views.discover_page),
|
||||
re_path(r'^notifications/?$', views.notifications_page),
|
||||
re_path(r'^direct-messages/?$', views.direct_messages_page),
|
||||
re_path(r'^import/?$', views.import_page),
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
''' defining regexes for regularly used concepts '''
|
||||
|
||||
domain = r'[a-z-A-Z0-9_\-]+\.[a-z]+'
|
||||
username = r'@[a-zA-Z_\-\.0-9]+(@%s)?' % domain
|
||||
full_username = r'@?[a-zA-Z_\-\.0-9]+@%s' % domain
|
||||
localname = r'@?[a-zA-Z_\-\.0-9]+'
|
||||
strict_localname = r'@[a-zA-Z_\-\.0-9]+'
|
||||
username = r'%s(@%s)?' % (localname, domain)
|
||||
strict_username = r'%s(@%s)?' % (strict_localname, domain)
|
||||
full_username = r'%s@%s' % (localname, domain)
|
||||
# should match (BookWyrm/1.0.0; or (BookWyrm/99.1.2;
|
||||
bookwyrm_user_agent = r'\(BookWyrm/[0-9]+\.[0-9]+\.[0-9]+;'
|
||||
|
|
|
@ -17,11 +17,12 @@ from django.template.response import TemplateResponse
|
|||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
|
||||
from bookwyrm import books_manager, forms, models, outgoing, goodreads_import
|
||||
from bookwyrm import forms, models, outgoing, goodreads_import
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from bookwyrm.broadcast import broadcast
|
||||
from bookwyrm.emailing import password_reset_email
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.views import get_user_from_username
|
||||
from bookwyrm.views import get_user_from_username, get_edition
|
||||
|
||||
|
||||
@require_POST
|
||||
|
@ -211,10 +212,8 @@ def edit_profile(request):
|
|||
def resolve_book(request):
|
||||
''' figure out the local path to a book from a remote_id '''
|
||||
remote_id = request.POST.get('remote_id')
|
||||
connector = books_manager.get_or_create_connector(remote_id)
|
||||
connector = connector_manager.get_or_create_connector(remote_id)
|
||||
book = connector.get_or_create_book(remote_id)
|
||||
if book.connector:
|
||||
books_manager.load_more_data.delay(book.id)
|
||||
|
||||
return redirect('/book/%d' % book.id)
|
||||
|
||||
|
@ -372,7 +371,7 @@ def delete_shelf(request, shelf_id):
|
|||
@require_POST
|
||||
def shelve(request):
|
||||
''' put a on a user's shelf '''
|
||||
book = books_manager.get_edition(request.POST['book'])
|
||||
book = get_edition(request.POST['book'])
|
||||
|
||||
desired_shelf = models.Shelf.objects.filter(
|
||||
identifier=request.POST['shelf'],
|
||||
|
@ -418,7 +417,7 @@ def unshelve(request):
|
|||
@require_POST
|
||||
def start_reading(request, book_id):
|
||||
''' begin reading a book '''
|
||||
book = books_manager.get_edition(book_id)
|
||||
book = get_edition(book_id)
|
||||
shelf = models.Shelf.objects.filter(
|
||||
identifier='reading',
|
||||
user=request.user
|
||||
|
@ -454,7 +453,7 @@ def start_reading(request, book_id):
|
|||
@require_POST
|
||||
def finish_reading(request, book_id):
|
||||
''' a user completed a book, yay '''
|
||||
book = books_manager.get_edition(book_id)
|
||||
book = get_edition(book_id)
|
||||
shelf = models.Shelf.objects.filter(
|
||||
identifier='read',
|
||||
user=request.user
|
||||
|
@ -578,14 +577,14 @@ def tag(request):
|
|||
tag_obj, created = models.Tag.objects.get_or_create(
|
||||
name=name,
|
||||
)
|
||||
user_tag = models.UserTag.objects.get_or_create(
|
||||
user_tag, _ = models.UserTag.objects.get_or_create(
|
||||
user=request.user,
|
||||
book=book,
|
||||
tag=tag_obj,
|
||||
)
|
||||
|
||||
if created:
|
||||
outgoing.handle_tag(request.user, user_tag)
|
||||
broadcast(request.user, user_tag.to_add_activity(request.user))
|
||||
return redirect('/book/%s' % book_id)
|
||||
|
||||
|
||||
|
@ -594,9 +593,16 @@ def tag(request):
|
|||
def untag(request):
|
||||
''' untag a book '''
|
||||
name = request.POST.get('name')
|
||||
tag_obj = get_object_or_404(models.Tag, name=name)
|
||||
book_id = request.POST.get('book')
|
||||
book = get_object_or_404(models.Edition, id=book_id)
|
||||
|
||||
outgoing.handle_untag(request.user, book_id, name)
|
||||
user_tag = get_object_or_404(
|
||||
models.UserTag, tag=tag_obj, book=book, user=request.user)
|
||||
tag_activity = user_tag.to_remove_activity(request.user)
|
||||
user_tag.delete()
|
||||
|
||||
broadcast(request.user, tag_activity)
|
||||
return redirect('/book/%s' % book_id)
|
||||
|
||||
|
||||
|
|
|
@ -4,7 +4,8 @@ import re
|
|||
from django.contrib.auth.decorators import login_required, permission_required
|
||||
from django.contrib.postgres.search import TrigramSimilarity
|
||||
from django.core.paginator import Paginator
|
||||
from django.db.models import Avg, Q
|
||||
from django.db.models import Avg, Q, Max
|
||||
from django.db.models.functions import Greatest
|
||||
from django.http import HttpResponseNotFound, JsonResponse
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
|
@ -13,21 +14,28 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
from django.views.decorators.http import require_GET
|
||||
|
||||
from bookwyrm import outgoing
|
||||
from bookwyrm.activitypub import ActivityEncoder
|
||||
from bookwyrm import forms, models, books_manager
|
||||
from bookwyrm import goodreads_import
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.activitypub import ActivitypubResponse
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from bookwyrm.settings import PAGE_LENGTH
|
||||
from bookwyrm.tasks import app
|
||||
from bookwyrm.utils import regex
|
||||
|
||||
|
||||
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()
|
||||
return book
|
||||
|
||||
def get_user_from_username(username):
|
||||
''' helper function to resolve a localname or a username to a user '''
|
||||
# raises DoesNotExist if user is now found
|
||||
try:
|
||||
user = models.User.objects.get(localname=username)
|
||||
return models.User.objects.get(localname=username)
|
||||
except models.User.DoesNotExist:
|
||||
user = models.User.objects.get(username=username)
|
||||
return user
|
||||
return models.User.objects.get(username=username)
|
||||
|
||||
|
||||
def is_api_request(request):
|
||||
|
@ -35,22 +43,33 @@ def is_api_request(request):
|
|||
return 'json' in request.headers.get('Accept') or \
|
||||
request.path[-5:] == '.json'
|
||||
|
||||
def is_bookworm_request(request):
|
||||
''' check if the request is coming from another bookworm instance '''
|
||||
user_agent = request.headers.get('User-Agent')
|
||||
if user_agent is None or \
|
||||
re.search(regex.bookwyrm_user_agent, user_agent) is None:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def server_error_page(request):
|
||||
''' 500 errors '''
|
||||
return TemplateResponse(request, 'error.html', {'title': 'Oops!'})
|
||||
return TemplateResponse(
|
||||
request, 'error.html', {'title': 'Oops!'}, status=500)
|
||||
|
||||
|
||||
def not_found_page(request, _):
|
||||
''' 404s '''
|
||||
return TemplateResponse(request, 'notfound.html', {'title': 'Not found'})
|
||||
return TemplateResponse(
|
||||
request, 'notfound.html', {'title': 'Not found'}, status=404)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def home(request):
|
||||
''' this is the same as the feed on the home tab '''
|
||||
return home_tab(request, 'home')
|
||||
if request.user.is_authenticated:
|
||||
return home_tab(request, 'home')
|
||||
return discover_page(request)
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -113,6 +132,36 @@ def get_suggested_books(user, max_books=5):
|
|||
return suggested_books
|
||||
|
||||
|
||||
@require_GET
|
||||
def discover_page(request):
|
||||
''' tiled book activity page '''
|
||||
books = models.Edition.objects.filter(
|
||||
review__published_date__isnull=False,
|
||||
review__user__local=True,
|
||||
review__privacy__in=['public', 'unlisted'],
|
||||
).exclude(
|
||||
cover__exact=''
|
||||
).annotate(
|
||||
Max('review__published_date')
|
||||
).order_by('-review__published_date__max')[:6]
|
||||
|
||||
ratings = {}
|
||||
for book in books:
|
||||
reviews = models.Review.objects.filter(
|
||||
book__in=book.parent_work.editions.all()
|
||||
)
|
||||
reviews = get_activity_feed(
|
||||
request.user, 'federated', model=reviews)
|
||||
ratings[book.id] = reviews.aggregate(Avg('rating'))['rating__avg']
|
||||
data = {
|
||||
'title': 'Discover',
|
||||
'register_form': forms.RegisterForm(),
|
||||
'books': list(set(books)),
|
||||
'ratings': ratings
|
||||
}
|
||||
return TemplateResponse(request, 'discover.html', data)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_GET
|
||||
def direct_messages_page(request, page=1):
|
||||
|
@ -164,7 +213,7 @@ def get_activity_feed(user, filter_level, model=models.Status):
|
|||
return activities.filter(
|
||||
Q(user=user) | Q(mention_users=user),
|
||||
privacy='direct'
|
||||
)
|
||||
).distinct()
|
||||
|
||||
# never show DMs in the regular feed
|
||||
activities = activities.filter(~Q(privacy='direct'))
|
||||
|
@ -179,7 +228,7 @@ def get_activity_feed(user, filter_level, model=models.Status):
|
|||
Q(user__in=following, privacy__in=[
|
||||
'public', 'unlisted', 'followers'
|
||||
]) | Q(mention_users=user) | Q(user=user)
|
||||
)
|
||||
).distinct()
|
||||
elif filter_level == 'self':
|
||||
activities = activities.filter(user=user, privacy='public')
|
||||
elif filter_level == 'local':
|
||||
|
@ -209,8 +258,8 @@ def search(request):
|
|||
|
||||
if is_api_request(request):
|
||||
# only return local book results via json so we don't cause a cascade
|
||||
book_results = books_manager.local_search(query)
|
||||
return JsonResponse([r.__dict__ for r in book_results], safe=False)
|
||||
book_results = connector_manager.local_search(query)
|
||||
return JsonResponse([r.json() for r in book_results], safe=False)
|
||||
|
||||
# use webfinger for mastodon style account@domain.com username
|
||||
if re.match(regex.full_username, query):
|
||||
|
@ -218,12 +267,15 @@ def search(request):
|
|||
|
||||
# do a local user search
|
||||
user_results = models.User.objects.annotate(
|
||||
similarity=TrigramSimilarity('username', query),
|
||||
similarity=Greatest(
|
||||
TrigramSimilarity('username', query),
|
||||
TrigramSimilarity('localname', query),
|
||||
)
|
||||
).filter(
|
||||
similarity__gt=0.5,
|
||||
).order_by('-similarity')[:10]
|
||||
|
||||
book_results = books_manager.search(query)
|
||||
book_results = connector_manager.search(query)
|
||||
data = {
|
||||
'title': 'Search Results',
|
||||
'book_results': book_results,
|
||||
|
@ -242,7 +294,6 @@ def import_page(request):
|
|||
'import_form': forms.ImportForm(),
|
||||
'jobs': models.ImportJob.
|
||||
objects.filter(user=request.user).order_by('-created_date'),
|
||||
'limit': goodreads_import.MAX_ENTRIES,
|
||||
})
|
||||
|
||||
|
||||
|
@ -378,7 +429,7 @@ def user_page(request, username):
|
|||
|
||||
if is_api_request(request):
|
||||
# we have a json request
|
||||
return JsonResponse(user.to_activity(), encoder=ActivityEncoder)
|
||||
return ActivitypubResponse(user.to_activity())
|
||||
# otherwise we're at a UI view
|
||||
|
||||
try:
|
||||
|
@ -403,7 +454,7 @@ def user_page(request, username):
|
|||
continue
|
||||
shelf_preview.append({
|
||||
'name': user_shelf.name,
|
||||
'remote_id': user_shelf.remote_id,
|
||||
'local_path': user_shelf.local_path,
|
||||
'books': user_shelf.books.all()[:3],
|
||||
'size': user_shelf.books.count(),
|
||||
})
|
||||
|
@ -446,7 +497,7 @@ def followers_page(request, username):
|
|||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(user.to_followers_activity(**request.GET))
|
||||
return ActivitypubResponse(user.to_followers_activity(**request.GET))
|
||||
|
||||
data = {
|
||||
'title': '%s: followers' % user.name,
|
||||
|
@ -467,7 +518,7 @@ def following_page(request, username):
|
|||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(user.to_following_activity(**request.GET))
|
||||
return ActivitypubResponse(user.to_following_activity(**request.GET))
|
||||
|
||||
data = {
|
||||
'title': '%s: following' % user.name,
|
||||
|
@ -497,7 +548,8 @@ def status_page(request, username, status_id):
|
|||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(status.to_activity(), encoder=ActivityEncoder)
|
||||
return ActivitypubResponse(
|
||||
status.to_activity(pure=not is_bookworm_request(request)))
|
||||
|
||||
data = {
|
||||
'title': 'Status by %s' % user.username,
|
||||
|
@ -530,10 +582,7 @@ def replies_page(request, username, status_id):
|
|||
if status.user.localname != username:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
return JsonResponse(
|
||||
status.to_replies(**request.GET),
|
||||
encoder=ActivityEncoder
|
||||
)
|
||||
return ActivitypubResponse(status.to_replies(**request.GET))
|
||||
|
||||
|
||||
@login_required
|
||||
|
@ -565,7 +614,7 @@ def book_page(request, book_id):
|
|||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(book.to_activity(), encoder=ActivityEncoder)
|
||||
return ActivitypubResponse(book.to_activity())
|
||||
|
||||
if isinstance(book, models.Work):
|
||||
book = book.get_default_edition()
|
||||
|
@ -646,7 +695,7 @@ def book_page(request, book_id):
|
|||
@require_GET
|
||||
def edit_book_page(request, book_id):
|
||||
''' info about a book '''
|
||||
book = books_manager.get_edition(book_id)
|
||||
book = get_edition(book_id)
|
||||
if not book.description:
|
||||
book.description = book.parent_work.description
|
||||
data = {
|
||||
|
@ -677,10 +726,7 @@ def editions_page(request, book_id):
|
|||
work = get_object_or_404(models.Work, id=book_id)
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(
|
||||
work.to_edition_list(**request.GET),
|
||||
encoder=ActivityEncoder
|
||||
)
|
||||
return ActivitypubResponse(work.to_edition_list(**request.GET))
|
||||
|
||||
data = {
|
||||
'title': 'Editions of %s' % work.title,
|
||||
|
@ -696,7 +742,7 @@ def author_page(request, author_id):
|
|||
author = get_object_or_404(models.Author, id=author_id)
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(author.to_activity(), encoder=ActivityEncoder)
|
||||
return ActivitypubResponse(author.to_activity())
|
||||
|
||||
books = models.Work.objects.filter(
|
||||
Q(authors=author) | Q(editions__authors=author)).distinct()
|
||||
|
@ -716,8 +762,7 @@ def tag_page(request, tag_id):
|
|||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(
|
||||
tag_obj.to_activity(**request.GET), encoder=ActivityEncoder)
|
||||
return ActivitypubResponse(tag_obj.to_activity(**request.GET))
|
||||
|
||||
books = models.Edition.objects.filter(
|
||||
usertag__tag__identifier=tag_id
|
||||
|
@ -768,7 +813,11 @@ def shelf_page(request, username, shelf_identifier):
|
|||
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(shelf.to_activity(**request.GET))
|
||||
return ActivitypubResponse(shelf.to_activity(**request.GET))
|
||||
|
||||
books = models.ShelfBook.objects.filter(
|
||||
added_by=user, shelf=shelf
|
||||
).order_by('-updated_date').all()
|
||||
|
||||
data = {
|
||||
'title': '%s\'s %s shelf' % (user.display_name, shelf.name),
|
||||
|
@ -776,6 +825,7 @@ def shelf_page(request, username, shelf_identifier):
|
|||
'is_self': is_self,
|
||||
'shelves': shelves.all(),
|
||||
'shelf': shelf,
|
||||
'books': [b.book for b in books],
|
||||
}
|
||||
|
||||
return TemplateResponse(request, 'shelf.html', data)
|
||||
|
|
|
@ -6,7 +6,7 @@ from django.http import JsonResponse
|
|||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from bookwyrm.settings import DOMAIN, VERSION
|
||||
|
||||
|
||||
def webfinger(request):
|
||||
|
@ -76,7 +76,7 @@ def nodeinfo(request):
|
|||
'version': '2.0',
|
||||
'software': {
|
||||
'name': 'bookwyrm',
|
||||
'version': '0.0.1'
|
||||
'version': VERSION
|
||||
},
|
||||
'protocols': [
|
||||
'activitypub'
|
||||
|
|
|
@ -20,8 +20,9 @@ app.config_from_object('django.conf:settings', namespace='CELERY')
|
|||
# Load task modules from all registered Django app configs.
|
||||
app.autodiscover_tasks()
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='activitypub.base_activity')
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='books_manager')
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='broadcast')
|
||||
app.autodiscover_tasks(
|
||||
['bookwyrm'], related_name='connectors.abstract_connector')
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='emailing')
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='goodreads_import')
|
||||
app.autodiscover_tasks(['bookwyrm'], related_name='incoming')
|
||||
|
|
Loading…
Reference in a new issue