mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2025-02-08 23:32:21 +00:00
Merge pull request #165 from mouse-reeve/code-cleanup
Code cleanup and tests
This commit is contained in:
commit
bc5dba97ef
38 changed files with 2270 additions and 94 deletions
|
@ -1,6 +1,6 @@
|
||||||
''' bring activitypub functions into the namespace '''
|
''' bring activitypub functions into the namespace '''
|
||||||
from .actor import get_actor
|
from .actor import get_actor
|
||||||
from .book import get_book, get_author
|
from .book import get_book, get_author, get_shelf
|
||||||
from .create import get_create, get_update
|
from .create import get_create, get_update
|
||||||
from .follow import get_following, get_followers
|
from .follow import get_following, get_followers
|
||||||
from .follow import get_follow_request, get_unfollow, get_accept, get_reject
|
from .follow import get_follow_request, get_unfollow, get_accept, get_reject
|
||||||
|
|
|
@ -86,3 +86,42 @@ def get_author(author):
|
||||||
if hasattr(author, field):
|
if hasattr(author, field):
|
||||||
activity[field] = author.__getattribute__(field)
|
activity[field] = author.__getattribute__(field)
|
||||||
return activity
|
return activity
|
||||||
|
|
||||||
|
|
||||||
|
def get_shelf(shelf, page=None):
|
||||||
|
''' serialize shelf object '''
|
||||||
|
id_slug = shelf.absolute_id
|
||||||
|
if page:
|
||||||
|
return get_shelf_page(shelf, page)
|
||||||
|
count = shelf.books.count()
|
||||||
|
return {
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
'id': id_slug,
|
||||||
|
'type': 'OrderedCollection',
|
||||||
|
'totalItems': count,
|
||||||
|
'first': '%s?page=1' % id_slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_shelf_page(shelf, page):
|
||||||
|
''' list of books on a shelf '''
|
||||||
|
page = int(page)
|
||||||
|
page_length = 10
|
||||||
|
start = (page - 1) * page_length
|
||||||
|
end = start + page_length
|
||||||
|
shelf_page = shelf.books.all()[start:end]
|
||||||
|
id_slug = shelf.absolute_id
|
||||||
|
data = {
|
||||||
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
'id': '%s?page=%d' % (id_slug, page),
|
||||||
|
'type': 'OrderedCollectionPage',
|
||||||
|
'totalItems': shelf.books.count(),
|
||||||
|
'partOf': id_slug,
|
||||||
|
'orderedItems': [get_book(b) for b in shelf_page],
|
||||||
|
}
|
||||||
|
if end <= shelf.books.count():
|
||||||
|
# there are still more pages
|
||||||
|
data['next'] = '%s?page=%d' % (id_slug, page + 1)
|
||||||
|
if start > 0:
|
||||||
|
data['prev'] = '%s?page=%d' % (id_slug, page - 1)
|
||||||
|
return data
|
||||||
|
|
|
@ -12,7 +12,7 @@ def get_remove(*args):
|
||||||
|
|
||||||
|
|
||||||
def get_add_remove(user, book, shelf, action='Add'):
|
def get_add_remove(user, book, shelf, action='Add'):
|
||||||
''' format an Add or Remove json blob '''
|
''' format a shelve book json blob '''
|
||||||
uuid = uuid4()
|
uuid = uuid4()
|
||||||
return {
|
return {
|
||||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||||
|
|
|
@ -19,10 +19,13 @@ def get_public_recipients(user, software=None):
|
||||||
# TODO: eventually we may want to handle particular software differently
|
# TODO: eventually we may want to handle particular software differently
|
||||||
followers = followers.filter(fedireads_user=(software == 'fedireads'))
|
followers = followers.filter(fedireads_user=(software == 'fedireads'))
|
||||||
|
|
||||||
|
# we want shared inboxes when available
|
||||||
shared = followers.filter(
|
shared = followers.filter(
|
||||||
shared_inbox__isnull=False
|
shared_inbox__isnull=False
|
||||||
).values_list('shared_inbox', flat=True).distinct()
|
).values_list('shared_inbox', flat=True).distinct()
|
||||||
|
|
||||||
|
# if a user doesn't have a shared inbox, we need their personal inbox
|
||||||
|
# iirc pixelfed doesn't have shared inboxes
|
||||||
inboxes = followers.filter(
|
inboxes = followers.filter(
|
||||||
shared_inbox__isnull=True
|
shared_inbox__isnull=True
|
||||||
).values_list('inbox', flat=True)
|
).values_list('inbox', flat=True)
|
||||||
|
@ -33,7 +36,9 @@ def get_public_recipients(user, software=None):
|
||||||
def broadcast(sender, activity, software=None, \
|
def broadcast(sender, activity, software=None, \
|
||||||
privacy='public', direct_recipients=None):
|
privacy='public', direct_recipients=None):
|
||||||
''' send out an event '''
|
''' send out an event '''
|
||||||
|
# start with parsing the direct recipients
|
||||||
recipients = [u.inbox for u in direct_recipients or []]
|
recipients = [u.inbox for u in direct_recipients or []]
|
||||||
|
# and then add any other recipients
|
||||||
# TODO: other kinds of privacy
|
# TODO: other kinds of privacy
|
||||||
if privacy == 'public':
|
if privacy == 'public':
|
||||||
recipients += get_public_recipients(sender, software=software)
|
recipients += get_public_recipients(sender, software=software)
|
||||||
|
@ -69,7 +74,9 @@ def sign_and_send(sender, activity, destination):
|
||||||
]
|
]
|
||||||
message_to_sign = '\n'.join(signature_headers)
|
message_to_sign = '\n'.join(signature_headers)
|
||||||
|
|
||||||
# TODO: raise an error if the user doesn't have a private key
|
if not sender.private_key:
|
||||||
|
# this shouldn't happen. it would be bad if it happened.
|
||||||
|
raise ValueError('No private key found for sender')
|
||||||
signer = pkcs1_15.new(RSA.import_key(sender.private_key))
|
signer = pkcs1_15.new(RSA.import_key(sender.private_key))
|
||||||
signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8')))
|
signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8')))
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,7 @@ class AbstractConnector(ABC):
|
||||||
for field in fields:
|
for field in fields:
|
||||||
setattr(self, field, getattr(info, field))
|
setattr(self, field, getattr(info, field))
|
||||||
|
|
||||||
|
|
||||||
def is_available(self):
|
def is_available(self):
|
||||||
''' check if you're allowed to use this connector '''
|
''' check if you're allowed to use this connector '''
|
||||||
if self.max_query_count is not None:
|
if self.max_query_count is not None:
|
||||||
|
@ -266,25 +267,25 @@ def update_from_mappings(obj, data, mappings):
|
||||||
if key == 'id':
|
if key == 'id':
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if has_attr(obj, key):
|
try:
|
||||||
|
hasattr(obj, key)
|
||||||
|
except ValueError:
|
||||||
obj.__setattr__(key, formatter(value))
|
obj.__setattr__(key, formatter(value))
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
def has_attr(obj, key):
|
|
||||||
''' helper function to check if a model object has a key '''
|
|
||||||
try:
|
|
||||||
return hasattr(obj, key)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_date(date_string):
|
def get_date(date_string):
|
||||||
''' helper function to try to interpret dates '''
|
''' helper function to try to interpret dates '''
|
||||||
if not date_string:
|
if not date_string:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return pytz.utc.localize(parser.parse(date_string))
|
return pytz.utc.localize(parser.parse(date_string))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
return parser.parse(date_string)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ class Connector(AbstractConnector):
|
||||||
|
|
||||||
|
|
||||||
def is_work_data(self, data):
|
def is_work_data(self, data):
|
||||||
return not re.match(r'^OL\d+M$', data['key'])
|
return bool(re.match(r'^[\/\w]+OL\d+W$', data['key']))
|
||||||
|
|
||||||
|
|
||||||
def get_edition_from_work_data(self, data):
|
def get_edition_from_work_data(self, data):
|
||||||
|
@ -86,14 +86,13 @@ class Connector(AbstractConnector):
|
||||||
|
|
||||||
|
|
||||||
def format_search_result(self, doc):
|
def format_search_result(self, doc):
|
||||||
key = doc['key']
|
|
||||||
# build the absolute id from the openlibrary key
|
# build the absolute id from the openlibrary key
|
||||||
key = self.books_url + key
|
key = self.books_url + doc['key']
|
||||||
author = doc.get('author_name') or ['Unknown']
|
author = doc.get('author_name') or ['Unknown']
|
||||||
return SearchResult(
|
return SearchResult(
|
||||||
doc.get('title'),
|
doc.get('title'),
|
||||||
key,
|
key,
|
||||||
author[0],
|
', '.join(author),
|
||||||
doc.get('first_publish_year'),
|
doc.get('first_publish_year'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -101,7 +101,7 @@ class EditionForm(ModelForm):
|
||||||
'updated_date',
|
'updated_date',
|
||||||
'last_sync_date',
|
'last_sync_date',
|
||||||
|
|
||||||
'authors',
|
'authors',# TODO
|
||||||
'parent_work',
|
'parent_work',
|
||||||
'shelves',
|
'shelves',
|
||||||
'misc_identifiers',
|
'misc_identifiers',
|
||||||
|
|
|
@ -12,6 +12,7 @@ MAX_ENTRIES = 500
|
||||||
|
|
||||||
|
|
||||||
def create_job(user, csv_file):
|
def create_job(user, csv_file):
|
||||||
|
''' check over a csv and creates a database entry for the job'''
|
||||||
job = ImportJob.objects.create(user=user)
|
job = ImportJob.objects.create(user=user)
|
||||||
for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]):
|
for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]):
|
||||||
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
|
if not all(x in entry for x in ('ISBN13', 'Title', 'Author')):
|
||||||
|
@ -19,13 +20,17 @@ def create_job(user, csv_file):
|
||||||
ImportItem(job=job, index=index, data=entry).save()
|
ImportItem(job=job, index=index, data=entry).save()
|
||||||
return job
|
return job
|
||||||
|
|
||||||
|
|
||||||
def start_import(job):
|
def start_import(job):
|
||||||
|
''' initalizes a csv import job '''
|
||||||
result = import_data.delay(job.id)
|
result = import_data.delay(job.id)
|
||||||
job.task_id = result.id
|
job.task_id = result.id
|
||||||
job.save()
|
job.save()
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def import_data(job_id):
|
def import_data(job_id):
|
||||||
|
''' does the actual lookup work in a celery task '''
|
||||||
job = ImportJob.objects.get(id=job_id)
|
job = ImportJob.objects.get(id=job_id)
|
||||||
try:
|
try:
|
||||||
results = []
|
results = []
|
||||||
|
|
|
@ -42,6 +42,9 @@ def shared_inbox(request):
|
||||||
except json.decoder.JSONDecodeError:
|
except json.decoder.JSONDecodeError:
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
|
if not activity.get('object'):
|
||||||
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
verify_signature(request)
|
verify_signature(request)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -128,9 +131,13 @@ def verify_signature(request):
|
||||||
@app.task
|
@app.task
|
||||||
def handle_follow(activity):
|
def handle_follow(activity):
|
||||||
''' someone wants to follow a local user '''
|
''' someone wants to follow a local user '''
|
||||||
# figure out who they want to follow
|
# figure out who they want to follow -- not using get_or_create because
|
||||||
to_follow = models.User.objects.get(actor=activity['object'])
|
# we only allow you to follow local users
|
||||||
# figure out who they are
|
try:
|
||||||
|
to_follow = models.User.objects.get(actor=activity['object'])
|
||||||
|
except models.User.DoesNotExist:
|
||||||
|
return False
|
||||||
|
# figure out who the actor is
|
||||||
user = get_or_create_remote_user(activity['actor'])
|
user = get_or_create_remote_user(activity['actor'])
|
||||||
try:
|
try:
|
||||||
request = models.UserFollowRequest.objects.create(
|
request = models.UserFollowRequest.objects.create(
|
||||||
|
@ -165,14 +172,11 @@ def handle_follow(activity):
|
||||||
def handle_unfollow(activity):
|
def handle_unfollow(activity):
|
||||||
''' unfollow a local user '''
|
''' unfollow a local user '''
|
||||||
obj = activity['object']
|
obj = activity['object']
|
||||||
if not obj['type'] == 'Follow':
|
|
||||||
#idk how to undo other things
|
|
||||||
return HttpResponseNotFound()
|
|
||||||
try:
|
try:
|
||||||
requester = get_or_create_remote_user(obj['actor'])
|
requester = get_or_create_remote_user(obj['actor'])
|
||||||
to_unfollow = models.User.objects.get(actor=obj['object'])
|
to_unfollow = models.User.objects.get(actor=obj['object'])
|
||||||
except models.User.DoesNotExist:
|
except models.User.DoesNotExist:
|
||||||
return HttpResponseNotFound()
|
return False
|
||||||
|
|
||||||
to_unfollow.followers.remove(requester)
|
to_unfollow.followers.remove(requester)
|
||||||
|
|
||||||
|
@ -209,7 +213,7 @@ def handle_follow_reject(activity):
|
||||||
)
|
)
|
||||||
request.delete()
|
request.delete()
|
||||||
except models.UserFollowRequest.DoesNotExist:
|
except models.UserFollowRequest.DoesNotExist:
|
||||||
pass
|
return False
|
||||||
|
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
|
@ -217,46 +221,37 @@ def handle_create(activity):
|
||||||
''' someone did something, good on them '''
|
''' someone did something, good on them '''
|
||||||
user = get_or_create_remote_user(activity['actor'])
|
user = get_or_create_remote_user(activity['actor'])
|
||||||
|
|
||||||
if not 'object' in activity:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if user.local:
|
if user.local:
|
||||||
# we really oughtn't even be sending in this case
|
# we really oughtn't even be sending in this case
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if activity['object'].get('fedireadsType') and \
|
if activity['object'].get('fedireadsType') and \
|
||||||
'inReplyToBook' in activity['object']:
|
'inReplyToBook' in activity['object']:
|
||||||
try:
|
if activity['object']['fedireadsType'] == 'Review':
|
||||||
if activity['object']['fedireadsType'] == 'Review':
|
builder = status_builder.create_review_from_activity
|
||||||
builder = status_builder.create_review_from_activity
|
elif activity['object']['fedireadsType'] == 'Quotation':
|
||||||
elif activity['object']['fedireadsType'] == 'Quotation':
|
builder = status_builder.create_quotation_from_activity
|
||||||
builder = status_builder.create_quotation_from_activity
|
else:
|
||||||
else:
|
builder = status_builder.create_comment_from_activity
|
||||||
builder = status_builder.create_comment_from_activity
|
|
||||||
|
|
||||||
# create the status, it'll throw a valueerror if anything is missing
|
# create the status, it'll throw a ValueError if anything is missing
|
||||||
builder(user, activity['object'])
|
builder(user, activity['object'])
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
elif activity['object'].get('inReplyTo'):
|
elif activity['object'].get('inReplyTo'):
|
||||||
# only create the status if it's in reply to a status we already know
|
# only create the status if it's in reply to a status we already know
|
||||||
if not status_builder.get_status(activity['object']['inReplyTo']):
|
if not status_builder.get_status(activity['object']['inReplyTo']):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
try:
|
status = status_builder.create_status_from_activity(
|
||||||
status = status_builder.create_status_from_activity(
|
user,
|
||||||
user,
|
activity['object']
|
||||||
activity['object']
|
)
|
||||||
|
if status and status.reply_parent:
|
||||||
|
status_builder.create_notification(
|
||||||
|
status.reply_parent.user,
|
||||||
|
'REPLY',
|
||||||
|
related_user=status.user,
|
||||||
|
related_status=status,
|
||||||
)
|
)
|
||||||
if status and status.reply_parent:
|
|
||||||
status_builder.create_notification(
|
|
||||||
status.reply_parent.user,
|
|
||||||
'REPLY',
|
|
||||||
related_user=status.user,
|
|
||||||
related_status=status,
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@ -268,7 +263,7 @@ def handle_favorite(activity):
|
||||||
status = models.Status.objects.get(id=status_id)
|
status = models.Status.objects.get(id=status_id)
|
||||||
liker = get_or_create_remote_user(activity['actor'])
|
liker = get_or_create_remote_user(activity['actor'])
|
||||||
except (models.Status.DoesNotExist, models.User.DoesNotExist):
|
except (models.Status.DoesNotExist, models.User.DoesNotExist):
|
||||||
return
|
return False
|
||||||
|
|
||||||
if not liker.local:
|
if not liker.local:
|
||||||
status_builder.create_favorite_from_activity(liker, activity)
|
status_builder.create_favorite_from_activity(liker, activity)
|
||||||
|
@ -287,7 +282,7 @@ def handle_unfavorite(activity):
|
||||||
favorite_id = activity['object']['id']
|
favorite_id = activity['object']['id']
|
||||||
fav = status_builder.get_favorite(favorite_id)
|
fav = status_builder.get_favorite(favorite_id)
|
||||||
if not fav:
|
if not fav:
|
||||||
return HttpResponseNotFound()
|
return False
|
||||||
|
|
||||||
fav.delete()
|
fav.delete()
|
||||||
|
|
||||||
|
@ -300,7 +295,7 @@ def handle_boost(activity):
|
||||||
status = models.Status.objects.get(id=status_id)
|
status = models.Status.objects.get(id=status_id)
|
||||||
booster = get_or_create_remote_user(activity['actor'])
|
booster = get_or_create_remote_user(activity['actor'])
|
||||||
except (models.Status.DoesNotExist, models.User.DoesNotExist):
|
except (models.Status.DoesNotExist, models.User.DoesNotExist):
|
||||||
return HttpResponseNotFound()
|
return False
|
||||||
|
|
||||||
if not booster.local:
|
if not booster.local:
|
||||||
status_builder.create_boost_from_activity(booster, activity)
|
status_builder.create_boost_from_activity(booster, activity)
|
||||||
|
@ -315,10 +310,10 @@ def handle_boost(activity):
|
||||||
|
|
||||||
@app.task
|
@app.task
|
||||||
def handle_tag(activity):
|
def handle_tag(activity):
|
||||||
''' someone is tagging or shelving a book '''
|
''' someone is tagging a book '''
|
||||||
user = get_or_create_remote_user(activity['actor'])
|
user = get_or_create_remote_user(activity['actor'])
|
||||||
if not user.local:
|
if not user.local:
|
||||||
book = activity['target']['id'].split('/')[-1]
|
book = activity['target']['id']
|
||||||
status_builder.create_tag(user, book, activity['object']['name'])
|
status_builder.create_tag(user, book, activity['object']['name'])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ from django.db import models
|
||||||
|
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
|
|
||||||
# TODO maybe this should be in /models?
|
|
||||||
class FedireadsModel(models.Model):
|
class FedireadsModel(models.Model):
|
||||||
''' fields and functions for every model '''
|
''' fields and functions for every model '''
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
@ -12,6 +11,9 @@ class FedireadsModel(models.Model):
|
||||||
@property
|
@property
|
||||||
def absolute_id(self):
|
def absolute_id(self):
|
||||||
''' constructs the absolute reference to any db object '''
|
''' constructs the absolute reference to any db object '''
|
||||||
|
if self.remote_id:
|
||||||
|
return self.remote_id
|
||||||
|
|
||||||
base_path = 'https://%s' % DOMAIN
|
base_path = 'https://%s' % DOMAIN
|
||||||
if hasattr(self, 'user'):
|
if hasattr(self, 'user'):
|
||||||
base_path = self.user.absolute_id
|
base_path = self.user.absolute_id
|
|
@ -3,9 +3,10 @@ from django.utils import timezone
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
|
from fedireads import activitypub
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
from fedireads.utils.fields import JSONField, ArrayField
|
from fedireads.utils.fields import JSONField, ArrayField
|
||||||
from fedireads.utils.models import FedireadsModel
|
from .base_model import FedireadsModel
|
||||||
|
|
||||||
from fedireads.connectors.settings import CONNECTORS
|
from fedireads.connectors.settings import CONNECTORS
|
||||||
|
|
||||||
|
@ -110,6 +111,10 @@ class Book(FedireadsModel):
|
||||||
self.title,
|
self.title,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activitypub_serialize(self):
|
||||||
|
return activitypub.get_book(self)
|
||||||
|
|
||||||
|
|
||||||
class Work(Book):
|
class Work(Book):
|
||||||
''' a work (an abstract concept of a book that manifests in an edition) '''
|
''' a work (an abstract concept of a book that manifests in an edition) '''
|
||||||
|
@ -165,3 +170,7 @@ class Author(FedireadsModel):
|
||||||
models.CharField(max_length=255), blank=True, default=list
|
models.CharField(max_length=255), blank=True, default=list
|
||||||
)
|
)
|
||||||
bio = models.TextField(null=True, blank=True)
|
bio = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activitypub_serialize(self):
|
||||||
|
return activitypub.get_author(self)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
''' track progress of goodreads imports '''
|
||||||
import re
|
import re
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
|
@ -5,7 +6,7 @@ from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from fedireads import books_manager
|
from fedireads import books_manager
|
||||||
from fedireads.models import Edition, ReadThrough, User, Book
|
from fedireads.models import ReadThrough, User, Book
|
||||||
from fedireads.utils.fields import JSONField
|
from fedireads.utils.fields import JSONField
|
||||||
|
|
||||||
# Mapping goodreads -> fedireads shelf titles.
|
# Mapping goodreads -> fedireads shelf titles.
|
||||||
|
@ -32,6 +33,7 @@ def construct_search_term(title, author):
|
||||||
|
|
||||||
return ' '.join([title, author])
|
return ' '.join([title, author])
|
||||||
|
|
||||||
|
|
||||||
class ImportJob(models.Model):
|
class ImportJob(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
created_date = models.DateTimeField(default=timezone.now)
|
created_date = models.DateTimeField(default=timezone.now)
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
''' puttin' books on shelves '''
|
''' puttin' books on shelves '''
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from fedireads.utils.models import FedireadsModel
|
from fedireads import activitypub
|
||||||
|
from .base_model import FedireadsModel
|
||||||
|
|
||||||
|
|
||||||
class Shelf(FedireadsModel):
|
class Shelf(FedireadsModel):
|
||||||
|
|
|
@ -6,7 +6,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from model_utils.managers import InheritanceManager
|
from model_utils.managers import InheritanceManager
|
||||||
|
|
||||||
from fedireads.utils.models import FedireadsModel
|
from fedireads import activitypub
|
||||||
|
from .base_model import FedireadsModel
|
||||||
|
|
||||||
|
|
||||||
class Status(FedireadsModel):
|
class Status(FedireadsModel):
|
||||||
|
@ -48,6 +49,11 @@ class Status(FedireadsModel):
|
||||||
return '%s/%s/%d' % (base_path, model_name, self.id)
|
return '%s/%s/%d' % (base_path, model_name, self.id)
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activitypub_serialize(self):
|
||||||
|
return activitypub.get_status(self)
|
||||||
|
|
||||||
|
|
||||||
class Comment(Status):
|
class Comment(Status):
|
||||||
''' like a review but without a rating and transient '''
|
''' like a review but without a rating and transient '''
|
||||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||||
|
@ -58,6 +64,11 @@ class Comment(Status):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activitypub_serialize(self):
|
||||||
|
return activitypub.get_comment(self)
|
||||||
|
|
||||||
|
|
||||||
class Quotation(Status):
|
class Quotation(Status):
|
||||||
''' like a review but without a rating and transient '''
|
''' like a review but without a rating and transient '''
|
||||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||||
|
@ -69,6 +80,11 @@ class Quotation(Status):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activitypub_serialize(self):
|
||||||
|
return activitypub.get_quotation(self)
|
||||||
|
|
||||||
|
|
||||||
class Review(Status):
|
class Review(Status):
|
||||||
''' a book review '''
|
''' a book review '''
|
||||||
name = models.CharField(max_length=255, null=True)
|
name = models.CharField(max_length=255, null=True)
|
||||||
|
@ -86,19 +102,17 @@ class Review(Status):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activitypub_serialize(self):
|
||||||
|
return activitypub.get_review(self)
|
||||||
|
|
||||||
|
|
||||||
class Favorite(FedireadsModel):
|
class Favorite(FedireadsModel):
|
||||||
''' fav'ing a post '''
|
''' fav'ing a post '''
|
||||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||||
status = models.ForeignKey('Status', on_delete=models.PROTECT)
|
status = models.ForeignKey('Status', on_delete=models.PROTECT)
|
||||||
remote_id = models.CharField(max_length=255, unique=True, null=True)
|
remote_id = models.CharField(max_length=255, unique=True, null=True)
|
||||||
|
|
||||||
@property
|
|
||||||
def absolute_id(self):
|
|
||||||
''' constructs the absolute reference to any db object '''
|
|
||||||
if self.remote_id:
|
|
||||||
return self.remote_id
|
|
||||||
return super().absolute_id
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('user', 'status')
|
unique_together = ('user', 'status')
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.dispatch import receiver
|
||||||
|
|
||||||
from fedireads.models.shelf import Shelf
|
from fedireads.models.shelf import Shelf
|
||||||
from fedireads.settings import DOMAIN
|
from fedireads.settings import DOMAIN
|
||||||
from fedireads.utils.models import FedireadsModel
|
from .base_model import FedireadsModel
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
|
@ -73,6 +73,10 @@ class User(AbstractUser):
|
||||||
username = self.localname or self.username
|
username = self.localname or self.username
|
||||||
return 'https://%s/%s/%s' % (DOMAIN, model_name, username)
|
return 'https://%s/%s/%s' % (DOMAIN, model_name, username)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def activitypub_serialize(self):
|
||||||
|
return activitypub.get_actor(self)
|
||||||
|
|
||||||
|
|
||||||
class UserRelationship(FedireadsModel):
|
class UserRelationship(FedireadsModel):
|
||||||
''' many-to-many through table for followers '''
|
''' many-to-many through table for followers '''
|
||||||
|
|
|
@ -19,19 +19,19 @@ class InputHtmlParser(HTMLParser):
|
||||||
self.output.append(('tag', self.get_starttag_text()))
|
self.output.append(('tag', self.get_starttag_text()))
|
||||||
self.tag_stack.append(tag)
|
self.tag_stack.append(tag)
|
||||||
else:
|
else:
|
||||||
self.output.append(('data', ' '))
|
self.output.append(('data', ''))
|
||||||
|
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
def handle_endtag(self, tag):
|
||||||
''' keep the close tag '''
|
''' keep the close tag '''
|
||||||
if not self.allow_html or tag not in self.whitelist:
|
if not self.allow_html or tag not in self.whitelist:
|
||||||
self.output.append(('data', ' '))
|
self.output.append(('data', ''))
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.tag_stack or self.tag_stack[-1] != tag:
|
if not self.tag_stack or self.tag_stack[-1] != tag:
|
||||||
# the end tag doesn't match the most recent start tag
|
# the end tag doesn't match the most recent start tag
|
||||||
self.allow_html = False
|
self.allow_html = False
|
||||||
self.output.append(('data', ' '))
|
self.output.append(('data', ''))
|
||||||
return
|
return
|
||||||
|
|
||||||
self.tag_stack = self.tag_stack[:-1]
|
self.tag_stack = self.tag_stack[:-1]
|
||||||
|
@ -45,6 +45,8 @@ class InputHtmlParser(HTMLParser):
|
||||||
|
|
||||||
def get_output(self):
|
def get_output(self):
|
||||||
''' convert the output from a list of tuples to a string '''
|
''' convert the output from a list of tuples to a string '''
|
||||||
|
if self.tag_stack:
|
||||||
|
self.allow_html = False
|
||||||
if not self.allow_html:
|
if not self.allow_html:
|
||||||
return ''.join(v for (k, v) in self.output if k == 'data')
|
return ''.join(v for (k, v) in self.output if k == 'data')
|
||||||
return ''.join(v for (k, v) in self.output)
|
return ''.join(v for (k, v) in self.output)
|
||||||
|
|
1
fedireads/tests/__init__.py
Normal file
1
fedireads/tests/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
from . import *
|
42
fedireads/tests/data/fr_edition.json
Normal file
42
fedireads/tests/data/fr_edition.json
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "Document",
|
||||||
|
"book_type": "Edition",
|
||||||
|
"name": "Jonathan Strange and Mr Norrell",
|
||||||
|
"url": "https://example.com/book/122",
|
||||||
|
"authors": [
|
||||||
|
"https://example.com/author/25"
|
||||||
|
],
|
||||||
|
"published_date": "2017-05-10T00:00:00+00:00",
|
||||||
|
"work": {
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "Document",
|
||||||
|
"book_type": "Work",
|
||||||
|
"name": "Jonathan Strange and Mr Norrell",
|
||||||
|
"url": "https://example.com/book/121",
|
||||||
|
"authors": [
|
||||||
|
"https://example.com/author/25"
|
||||||
|
],
|
||||||
|
"title": "Jonathan Strange and Mr Norrell",
|
||||||
|
"attachment": [
|
||||||
|
{
|
||||||
|
"type": "Document",
|
||||||
|
"mediaType": "image/jpg",
|
||||||
|
"url": "https://example.com/images/covers/8775540-M.jpg",
|
||||||
|
"name": "Cover of \"Jonathan Strange and Mr Norrell\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"title": "Jonathan Strange and Mr Norrell",
|
||||||
|
"subtitle": "Bloomsbury Modern Classics",
|
||||||
|
"isbn_13": "9781408891469",
|
||||||
|
"physical_format": "paperback",
|
||||||
|
"attachment": [
|
||||||
|
{
|
||||||
|
"type": "Document",
|
||||||
|
"mediaType": "image/jpg",
|
||||||
|
"url": "https://example.com/images/covers/9155821-M.jpg",
|
||||||
|
"name": "Cover of \"Jonathan Strange and Mr Norrell\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
1
fedireads/tests/data/fr_search.json
Normal file
1
fedireads/tests/data/fr_search.json
Normal file
|
@ -0,0 +1 @@
|
||||||
|
[{"title": "Jonathan Strange and Mr Norrell", "key": "https://example.com/book/122", "author": "Susanna Clarke", "year": 2017}]
|
44
fedireads/tests/data/fr_work.json
Normal file
44
fedireads/tests/data/fr_work.json
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
{
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "Document",
|
||||||
|
"book_type": "Work",
|
||||||
|
"name": "Jonathan Strange and Mr Norrell",
|
||||||
|
"url": "https://example.com/book/121",
|
||||||
|
"authors": [
|
||||||
|
"https://example.com/author/25"
|
||||||
|
],
|
||||||
|
"editions": [
|
||||||
|
{
|
||||||
|
"@context": "https://www.w3.org/ns/activitystreams",
|
||||||
|
"type": "Document",
|
||||||
|
"book_type": "Edition",
|
||||||
|
"name": "Jonathan Strange and Mr Norrell",
|
||||||
|
"url": "https://example.com/book/122",
|
||||||
|
"authors": [
|
||||||
|
"https://example.com/author/25"
|
||||||
|
],
|
||||||
|
"published_date": "2017-05-10T00:00:00+00:00",
|
||||||
|
"title": "Jonathan Strange and Mr Norrell",
|
||||||
|
"subtitle": "Bloomsbury Modern Classics",
|
||||||
|
"isbn_13": "9781408891469",
|
||||||
|
"physical_format": "paperback",
|
||||||
|
"attachment": [
|
||||||
|
{
|
||||||
|
"type": "Document",
|
||||||
|
"mediaType": "image/jpg",
|
||||||
|
"url": "https://example.com/images/covers/9155821-M.jpg",
|
||||||
|
"name": "Cover of \"Jonathan Strange and Mr Norrell\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"title": "Jonathan Strange and Mr Norrell",
|
||||||
|
"attachment": [
|
||||||
|
{
|
||||||
|
"type": "Document",
|
||||||
|
"mediaType": "image/jpg",
|
||||||
|
"url": "https://example.com/images/covers/8775540-M.jpg",
|
||||||
|
"name": "Cover of \"Jonathan Strange and Mr Norrell\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
83
fedireads/tests/data/ol_edition.json
Normal file
83
fedireads/tests/data/ol_edition.json
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
{
|
||||||
|
"identifiers": {
|
||||||
|
"librarything": [
|
||||||
|
"10014"
|
||||||
|
],
|
||||||
|
"goodreads": [
|
||||||
|
"535197",
|
||||||
|
"1102517",
|
||||||
|
"518848"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"lc_classifications": [
|
||||||
|
"PZ7.N647 Sab 1995"
|
||||||
|
],
|
||||||
|
"latest_revision": 7,
|
||||||
|
"ocaid": "sabriel00nixg",
|
||||||
|
"ia_box_id": [
|
||||||
|
"IA107202"
|
||||||
|
],
|
||||||
|
"edition_name": "1st American ed.",
|
||||||
|
"title": "Sabriel",
|
||||||
|
"languages": [
|
||||||
|
{
|
||||||
|
"key": "/languages/eng"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"subjects": [
|
||||||
|
"Fantasy."
|
||||||
|
],
|
||||||
|
"publish_country": "nyu",
|
||||||
|
"by_statement": "Garth Nix.",
|
||||||
|
"type": {
|
||||||
|
"key": "/type/edition"
|
||||||
|
},
|
||||||
|
"revision": 7,
|
||||||
|
"publishers": [
|
||||||
|
"Harper Trophy"
|
||||||
|
],
|
||||||
|
"description": {
|
||||||
|
"type": "/type/text",
|
||||||
|
"value": "Sabriel, daughter of the necromancer Abhorsen, must journey into the mysterious and magical Old Kingdom to rescue her father from the Land of the Dead."
|
||||||
|
},
|
||||||
|
"last_modified": {
|
||||||
|
"type": "/type/datetime",
|
||||||
|
"value": "2017-10-08T21:20:07.665236"
|
||||||
|
},
|
||||||
|
"key": "/books/OL22951843M",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"key": "/authors/OL382982A"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"publish_places": [
|
||||||
|
"New York"
|
||||||
|
],
|
||||||
|
"pagination": "491 p. :",
|
||||||
|
"created": {
|
||||||
|
"type": "/type/datetime",
|
||||||
|
"value": "2009-02-12T16:29:58.929717"
|
||||||
|
},
|
||||||
|
"dewey_decimal_class": [
|
||||||
|
"[Fic]"
|
||||||
|
],
|
||||||
|
"notes": {
|
||||||
|
"type": "/type/text",
|
||||||
|
"value": "Originally published: Australia : HarperCollins, 1995."
|
||||||
|
},
|
||||||
|
"number_of_pages": 491,
|
||||||
|
"lccn": [
|
||||||
|
"96001295"
|
||||||
|
],
|
||||||
|
"isbn_10": [
|
||||||
|
"0060273224",
|
||||||
|
"0060273232",
|
||||||
|
"0064471837"
|
||||||
|
],
|
||||||
|
"publish_date": "1996",
|
||||||
|
"works": [
|
||||||
|
{
|
||||||
|
"key": "/works/OL15832982W"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
1018
fedireads/tests/data/ol_edition_list.json
Normal file
1018
fedireads/tests/data/ol_edition_list.json
Normal file
File diff suppressed because it is too large
Load diff
132
fedireads/tests/data/ol_search.json
Normal file
132
fedireads/tests/data/ol_search.json
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
{
|
||||||
|
"start": 0,
|
||||||
|
"num_found": 2,
|
||||||
|
"numFound": 2,
|
||||||
|
"docs": [
|
||||||
|
{
|
||||||
|
"title_suggest": "This Is How You Lose the Time War",
|
||||||
|
"edition_key": [
|
||||||
|
"OL27901088M"
|
||||||
|
],
|
||||||
|
"isbn": [
|
||||||
|
"9781534431003",
|
||||||
|
"1534431004"
|
||||||
|
],
|
||||||
|
"has_fulltext": false,
|
||||||
|
"text": [
|
||||||
|
"OL27901088M",
|
||||||
|
"9781534431003",
|
||||||
|
"1534431004",
|
||||||
|
"Amal El-Mohtar",
|
||||||
|
"Max Gladstone",
|
||||||
|
"OL7313207A",
|
||||||
|
"OL7129451A",
|
||||||
|
"epistolary",
|
||||||
|
"science fiction",
|
||||||
|
"time-traveling",
|
||||||
|
"LGBT",
|
||||||
|
"This Is How You Lose the Time War",
|
||||||
|
"/works/OL20639540W",
|
||||||
|
"Simon and Schuster",
|
||||||
|
"Atlantis",
|
||||||
|
"London",
|
||||||
|
"The whole of time and space"
|
||||||
|
],
|
||||||
|
"author_name": [
|
||||||
|
"Amal El-Mohtar",
|
||||||
|
"Max Gladstone"
|
||||||
|
],
|
||||||
|
"seed": [
|
||||||
|
"/books/OL27901088M",
|
||||||
|
"/works/OL20639540W",
|
||||||
|
"/subjects/science_fiction",
|
||||||
|
"/subjects/time-traveling",
|
||||||
|
"/subjects/epistolary",
|
||||||
|
"/subjects/lgbt",
|
||||||
|
"/subjects/place:london",
|
||||||
|
"/subjects/place:atlantis",
|
||||||
|
"/subjects/time:the_whole_of_time_and_space",
|
||||||
|
"/authors/OL7313207A",
|
||||||
|
"/authors/OL7129451A"
|
||||||
|
],
|
||||||
|
"author_key": [
|
||||||
|
"OL7313207A",
|
||||||
|
"OL7129451A"
|
||||||
|
],
|
||||||
|
"availability": {
|
||||||
|
"status": "error"
|
||||||
|
},
|
||||||
|
"subject": [
|
||||||
|
"epistolary",
|
||||||
|
"science fiction",
|
||||||
|
"time-traveling",
|
||||||
|
"LGBT"
|
||||||
|
],
|
||||||
|
"title": "This Is How You Lose the Time War",
|
||||||
|
"publish_date": [
|
||||||
|
"July 16, 2019"
|
||||||
|
],
|
||||||
|
"type": "work",
|
||||||
|
"ebook_count_i": 0,
|
||||||
|
"publish_place": [
|
||||||
|
"New York, USA"
|
||||||
|
],
|
||||||
|
"edition_count": 1,
|
||||||
|
"key": "/works/OL20639540W",
|
||||||
|
"publisher": [
|
||||||
|
"Simon and Schuster"
|
||||||
|
],
|
||||||
|
"language": [
|
||||||
|
"eng"
|
||||||
|
],
|
||||||
|
"last_modified_i": 1579909341,
|
||||||
|
"cover_edition_key": "OL27901088M",
|
||||||
|
"publish_year": [
|
||||||
|
2019
|
||||||
|
],
|
||||||
|
"first_publish_year": 2019,
|
||||||
|
"place": [
|
||||||
|
"Atlantis",
|
||||||
|
"London"
|
||||||
|
],
|
||||||
|
"time": [
|
||||||
|
"The whole of time and space"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title_suggest": "This is How You Lose the Time War",
|
||||||
|
"cover_i": 8665647,
|
||||||
|
"has_fulltext": false,
|
||||||
|
"title": "This is How You Lose the Time War",
|
||||||
|
"last_modified_i": 1561998020,
|
||||||
|
"edition_count": 0,
|
||||||
|
"author_name": [
|
||||||
|
"Amal El-Mohtar",
|
||||||
|
"Max Gladstone"
|
||||||
|
],
|
||||||
|
"seed": [
|
||||||
|
"/works/OL19859295W",
|
||||||
|
"/authors/OL7313207A",
|
||||||
|
"/authors/OL7129451A"
|
||||||
|
],
|
||||||
|
"key": "/works/OL19859295W",
|
||||||
|
"text": [
|
||||||
|
"Amal El-Mohtar",
|
||||||
|
"Max Gladstone",
|
||||||
|
"OL7313207A",
|
||||||
|
"OL7129451A",
|
||||||
|
"This is How You Lose the Time War",
|
||||||
|
"/works/OL19859295W"
|
||||||
|
],
|
||||||
|
"author_key": [
|
||||||
|
"OL7313207A",
|
||||||
|
"OL7129451A"
|
||||||
|
],
|
||||||
|
"type": "work",
|
||||||
|
"availability": {
|
||||||
|
"status": "error"
|
||||||
|
},
|
||||||
|
"ebook_count_i": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
63
fedireads/tests/data/ol_work.json
Normal file
63
fedireads/tests/data/ol_work.json
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
{
|
||||||
|
"first_publish_date": "1995",
|
||||||
|
"key": "/works/OL15832982W",
|
||||||
|
"description": {
|
||||||
|
"type": "/type/text",
|
||||||
|
"value": "First in the Old Kingdom/Abhorsen series."
|
||||||
|
},
|
||||||
|
"created": {
|
||||||
|
"type": "/type/datetime",
|
||||||
|
"value": "2011-07-07T16:30:28.384311"
|
||||||
|
},
|
||||||
|
"title": "Sabriel",
|
||||||
|
"covers": [
|
||||||
|
6796986,
|
||||||
|
3843137
|
||||||
|
],
|
||||||
|
"first_sentence": {
|
||||||
|
"type": "/type/text",
|
||||||
|
"value": "THE RABBIT HAD been run over minutes before."
|
||||||
|
},
|
||||||
|
"excerpts": [
|
||||||
|
{
|
||||||
|
"excerpt": "THE RABBIT HAD been run over minutes before."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lc_classifications": [
|
||||||
|
"PZ7.N647 Sab 1995"
|
||||||
|
],
|
||||||
|
"latest_revision": 5,
|
||||||
|
"last_modified": {
|
||||||
|
"type": "/type/datetime",
|
||||||
|
"value": "2019-07-22T13:57:34.579651"
|
||||||
|
},
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"type": {
|
||||||
|
"key": "/type/author_role"
|
||||||
|
},
|
||||||
|
"author": {
|
||||||
|
"key": "/authors/OL382982A"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dewey_number": [
|
||||||
|
"[Fic]"
|
||||||
|
],
|
||||||
|
"subjects": [
|
||||||
|
"Fantasy",
|
||||||
|
"Science Fiction & Fantasy",
|
||||||
|
"Fantasy fiction",
|
||||||
|
"Fiction",
|
||||||
|
"Juvenile Fiction",
|
||||||
|
"Juvenile fiction",
|
||||||
|
"Magical thinking"
|
||||||
|
],
|
||||||
|
"type": {
|
||||||
|
"key": "/type/work"
|
||||||
|
},
|
||||||
|
"subject_times": [
|
||||||
|
"Life and Death."
|
||||||
|
],
|
||||||
|
"revision": 5
|
||||||
|
}
|
44
fedireads/tests/test_book_model.py
Normal file
44
fedireads/tests/test_book_model.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
''' testing models '''
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models, settings
|
||||||
|
|
||||||
|
|
||||||
|
class Book(TestCase):
|
||||||
|
''' not too much going on in the books model but here we are '''
|
||||||
|
def setUp(self):
|
||||||
|
work = models.Work.objects.create(title='Example Work')
|
||||||
|
models.Edition.objects.create(title='Example Edition', parent_work=work)
|
||||||
|
|
||||||
|
def test_absolute_id(self):
|
||||||
|
''' editions and works use the same absolute id syntax '''
|
||||||
|
book = models.Edition.objects.first()
|
||||||
|
expected_id = 'https://%s/book/%d' % (settings.DOMAIN, book.id)
|
||||||
|
self.assertEqual(book.absolute_id, expected_id)
|
||||||
|
|
||||||
|
def test_create_book(self):
|
||||||
|
''' you shouldn't be able to create Books (only editions and works) '''
|
||||||
|
self.assertRaises(
|
||||||
|
ValueError,
|
||||||
|
models.Book.objects.create,
|
||||||
|
title='Invalid Book'
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_edition(self):
|
||||||
|
''' a work should always be able to produce a deafult edition '''
|
||||||
|
default_edition = models.Work.objects.first().default_edition
|
||||||
|
self.assertIsInstance(default_edition, models.Edition)
|
||||||
|
|
||||||
|
|
||||||
|
class Shelf(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
models.Shelf.objects.create(
|
||||||
|
name='Test Shelf', identifier='test-shelf', user=user)
|
||||||
|
|
||||||
|
def test_absolute_id(self):
|
||||||
|
''' editions and works use the same absolute id syntax '''
|
||||||
|
shelf = models.Shelf.objects.get(identifier='test-shelf')
|
||||||
|
expected_id = 'https://%s/user/mouse/shelf/test-shelf' % settings.DOMAIN
|
||||||
|
self.assertEqual(shelf.absolute_id, expected_id)
|
56
fedireads/tests/test_comment.py
Normal file
56
fedireads/tests/test_comment.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
from fedireads import status as status_builder
|
||||||
|
|
||||||
|
|
||||||
|
class Comment(TestCase):
|
||||||
|
''' we have hecka ways to create statuses '''
|
||||||
|
def setUp(self):
|
||||||
|
self.user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
self.book = models.Edition.objects.create(title='Example Edition')
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_comment(self):
|
||||||
|
comment = status_builder.create_comment(
|
||||||
|
self.user, self.book, 'commentary')
|
||||||
|
self.assertEqual(comment.content, 'commentary')
|
||||||
|
|
||||||
|
|
||||||
|
def test_comment_from_activity(self):
|
||||||
|
activity = {
|
||||||
|
"id": "https://example.com/user/mouse/comment/6",
|
||||||
|
"url": "https://example.com/user/mouse/comment/6",
|
||||||
|
"inReplyTo": None,
|
||||||
|
"published": "2020-05-08T23:45:44.768012+00:00",
|
||||||
|
"attributedTo": "https://example.com/user/mouse",
|
||||||
|
"to": [
|
||||||
|
"https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
],
|
||||||
|
"cc": [
|
||||||
|
"https://example.com/user/mouse/followers"
|
||||||
|
],
|
||||||
|
"sensitive": False,
|
||||||
|
"content": "commentary",
|
||||||
|
"type": "Note",
|
||||||
|
"attachment": [],
|
||||||
|
"replies": {
|
||||||
|
"id": "https://example.com/user/mouse/comment/6/replies",
|
||||||
|
"type": "Collection",
|
||||||
|
"first": {
|
||||||
|
"type": "CollectionPage",
|
||||||
|
"next": "https://example.com/user/mouse/comment/6/replies?only_other_accounts=true&page=true",
|
||||||
|
"partOf": "https://example.com/user/mouse/comment/6/replies",
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"inReplyToBook": self.book.absolute_id,
|
||||||
|
"fedireadsType": "Comment"
|
||||||
|
}
|
||||||
|
comment = status_builder.create_comment_from_activity(
|
||||||
|
self.user, activity)
|
||||||
|
self.assertEqual(comment.content, 'commentary')
|
||||||
|
self.assertEqual(comment.book, self.book)
|
||||||
|
self.assertEqual(
|
||||||
|
comment.published_date, '2020-05-08T23:45:44.768012+00:00')
|
65
fedireads/tests/test_connector_fedireads.py
Normal file
65
fedireads/tests/test_connector_fedireads.py
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
''' testing book data connectors '''
|
||||||
|
from dateutil import parser
|
||||||
|
from django.test import TestCase
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
from fedireads.connectors.fedireads_connector import Connector
|
||||||
|
from fedireads.connectors.abstract_connector import SearchResult, get_date
|
||||||
|
|
||||||
|
|
||||||
|
class FedireadsConnector(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
models.Connector.objects.create(
|
||||||
|
identifier='example.com',
|
||||||
|
connector_file='fedireads_connector',
|
||||||
|
base_url='https://example.com',
|
||||||
|
books_url='https://example.com',
|
||||||
|
covers_url='https://example.com/images/covers',
|
||||||
|
search_url='https://example.com/search?q=',
|
||||||
|
key_name='remote_id',
|
||||||
|
)
|
||||||
|
self.connector = Connector('example.com')
|
||||||
|
|
||||||
|
work_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/fr_work.json')
|
||||||
|
edition_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/fr_edition.json')
|
||||||
|
self.work_data = json.loads(work_file.read_bytes())
|
||||||
|
self.edition_data = json.loads(edition_file.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_work_data(self):
|
||||||
|
self.assertEqual(self.connector.is_work_data(self.work_data), True)
|
||||||
|
self.assertEqual(self.connector.is_work_data(self.edition_data), False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_edition_from_work_data(self):
|
||||||
|
edition = self.connector.get_edition_from_work_data(self.work_data)
|
||||||
|
self.assertEqual(edition['url'], 'https://example.com/book/122')
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_work_from_edition_data(self):
|
||||||
|
work = self.connector.get_work_from_edition_date(self.edition_data)
|
||||||
|
self.assertEqual(work['url'], 'https://example.com/book/121')
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_search_result(self):
|
||||||
|
datafile = pathlib.Path(__file__).parent.joinpath('data/fr_search.json')
|
||||||
|
search_data = json.loads(datafile.read_bytes())
|
||||||
|
results = self.connector.parse_search_data(search_data)
|
||||||
|
self.assertIsInstance(results, list)
|
||||||
|
|
||||||
|
result = self.connector.format_search_result(results[0])
|
||||||
|
self.assertIsInstance(result, SearchResult)
|
||||||
|
self.assertEqual(result.title, 'Jonathan Strange and Mr Norrell')
|
||||||
|
self.assertEqual(result.key, 'https://example.com/book/122')
|
||||||
|
self.assertEqual(result.author, 'Susanna Clarke')
|
||||||
|
self.assertEqual(result.year, 2017)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_date(self):
|
||||||
|
date = get_date(self.edition_data['published_date'])
|
||||||
|
expected = parser.parse("2017-05-10T00:00:00+00:00")
|
||||||
|
self.assertEqual(date, expected)
|
79
fedireads/tests/test_connector_openlibrary.py
Normal file
79
fedireads/tests/test_connector_openlibrary.py
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
''' testing book data connectors '''
|
||||||
|
from dateutil import parser
|
||||||
|
from django.test import TestCase
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
from fedireads.connectors.openlibrary import Connector
|
||||||
|
from fedireads.connectors.openlibrary import get_languages, get_description
|
||||||
|
from fedireads.connectors.openlibrary import pick_default_edition
|
||||||
|
from fedireads.connectors.abstract_connector import SearchResult, get_date
|
||||||
|
|
||||||
|
|
||||||
|
class Openlibrary(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
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=',
|
||||||
|
key_name='openlibrary_key',
|
||||||
|
)
|
||||||
|
self.connector = Connector('openlibrary.org')
|
||||||
|
|
||||||
|
work_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/ol_work.json')
|
||||||
|
edition_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/ol_edition.json')
|
||||||
|
edition_list_file = pathlib.Path(__file__).parent.joinpath(
|
||||||
|
'data/ol_edition_list.json')
|
||||||
|
self.work_data = json.loads(work_file.read_bytes())
|
||||||
|
self.edition_data = json.loads(edition_file.read_bytes())
|
||||||
|
self.edition_list_data = json.loads(edition_list_file.read_bytes())
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_work_data(self):
|
||||||
|
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/OL9952943M')
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_search_result(self):
|
||||||
|
''' translate json from openlibrary into SearchResult '''
|
||||||
|
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)
|
||||||
|
|
||||||
|
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.author, 'Amal El-Mohtar, Max Gladstone')
|
||||||
|
self.assertEqual(result.year, 2019)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_description(self):
|
||||||
|
description = get_description(self.work_data['description'])
|
||||||
|
expected = 'First in the Old Kingdom/Abhorsen series.'
|
||||||
|
self.assertEqual(description, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_date(self):
|
||||||
|
date = get_date(self.work_data['first_publish_date'])
|
||||||
|
expected = pytz.utc.localize(parser.parse('1995'))
|
||||||
|
self.assertEqual(date, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_languages(self):
|
||||||
|
languages = get_languages(self.edition_data['languages'])
|
||||||
|
self.assertEqual(languages, ['English'])
|
112
fedireads/tests/test_import_model.py
Normal file
112
fedireads/tests/test_import_model.py
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
''' testing models '''
|
||||||
|
import datetime
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
|
||||||
|
|
||||||
|
class ImportJob(TestCase):
|
||||||
|
''' this is a fancy one!!! '''
|
||||||
|
def setUp(self):
|
||||||
|
''' data is from a goodreads export of The Raven Tower '''
|
||||||
|
read_data = {
|
||||||
|
'Book Id': 39395857,
|
||||||
|
'Title': 'The Raven Tower',
|
||||||
|
'Author': 'Ann Leckie',
|
||||||
|
'Author l-f': 'Leckie, Ann',
|
||||||
|
'Additional Authors': '',
|
||||||
|
'ISBN': '="0356506991"',
|
||||||
|
'ISBN13': '="9780356506999"',
|
||||||
|
'My Rating': 0,
|
||||||
|
'Average Rating': 4.06,
|
||||||
|
'Publisher': 'Orbit',
|
||||||
|
'Binding': 'Hardcover',
|
||||||
|
'Number of Pages': 416,
|
||||||
|
'Year Published': 2019,
|
||||||
|
'Original Publication Year': 2019,
|
||||||
|
'Date Read': '2019/04/09',
|
||||||
|
'Date Added': '2019/04/09',
|
||||||
|
'Bookshelves': '',
|
||||||
|
'Bookshelves with positions': '',
|
||||||
|
'Exclusive Shelf': 'read',
|
||||||
|
'My Review': '',
|
||||||
|
'Spoiler': '',
|
||||||
|
'Private Notes': '',
|
||||||
|
'Read Count': 1,
|
||||||
|
'Recommended For': '',
|
||||||
|
'Recommended By': '',
|
||||||
|
'Owned Copies': 0,
|
||||||
|
'Original Purchase Date': '',
|
||||||
|
'Original Purchase Location': '',
|
||||||
|
'Condition': '',
|
||||||
|
'Condition Description': '',
|
||||||
|
'BCID': ''
|
||||||
|
}
|
||||||
|
currently_reading_data = read_data.copy()
|
||||||
|
currently_reading_data['Exclusive Shelf'] = 'currently-reading'
|
||||||
|
currently_reading_data['Date Read'] = ''
|
||||||
|
|
||||||
|
unknown_read_data = currently_reading_data.copy()
|
||||||
|
unknown_read_data['Exclusive Shelf'] = 'read'
|
||||||
|
unknown_read_data['Date Read'] = ''
|
||||||
|
|
||||||
|
user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
job = models.ImportJob.objects.create(user=user)
|
||||||
|
models.ImportItem.objects.create(
|
||||||
|
job=job, index=1, data=currently_reading_data)
|
||||||
|
models.ImportItem.objects.create(
|
||||||
|
job=job, index=2, data=read_data)
|
||||||
|
models.ImportItem.objects.create(
|
||||||
|
job=job, index=3, data=unknown_read_data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_isbn(self):
|
||||||
|
''' it unquotes the isbn13 field from data '''
|
||||||
|
expected = '9780356506999'
|
||||||
|
item = models.ImportItem.objects.get(index=1)
|
||||||
|
self.assertEqual(item.isbn, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_shelf(self):
|
||||||
|
''' converts to the local shelf typology '''
|
||||||
|
expected = 'reading'
|
||||||
|
item = models.ImportItem.objects.get(index=1)
|
||||||
|
self.assertEqual(item.shelf, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_added(self):
|
||||||
|
''' converts to the local shelf typology '''
|
||||||
|
expected = datetime.datetime(2019, 4, 9, 0, 0)
|
||||||
|
item = models.ImportItem.objects.get(index=1)
|
||||||
|
self.assertEqual(item.date_added, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_date_read(self):
|
||||||
|
''' converts to the local shelf typology '''
|
||||||
|
expected = datetime.datetime(2019, 4, 9, 0, 0)
|
||||||
|
item = models.ImportItem.objects.get(index=2)
|
||||||
|
self.assertEqual(item.date_read, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_currently_reading_reads(self):
|
||||||
|
expected = [models.ReadThrough(
|
||||||
|
start_date=datetime.datetime(2019, 4, 9, 0, 0))]
|
||||||
|
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):
|
||||||
|
expected = [models.ReadThrough(
|
||||||
|
finish_date=datetime.datetime(2019, 4, 9, 0, 0))]
|
||||||
|
actual = models.ImportItem.objects.get(index=2)
|
||||||
|
self.assertEqual(actual.reads[0].start_date, expected[0].start_date)
|
||||||
|
self.assertEqual(actual.reads[0].finish_date, expected[0].finish_date)
|
||||||
|
|
||||||
|
def test_unread_reads(self):
|
||||||
|
expected = []
|
||||||
|
actual = models.ImportItem.objects.get(index=3)
|
||||||
|
self.assertEqual(actual.reads, expected)
|
||||||
|
|
||||||
|
|
||||||
|
|
66
fedireads/tests/test_quotation.py
Normal file
66
fedireads/tests/test_quotation.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
from fedireads import status as status_builder
|
||||||
|
|
||||||
|
|
||||||
|
class Quotation(TestCase):
|
||||||
|
''' we have hecka ways to create statuses '''
|
||||||
|
def setUp(self):
|
||||||
|
self.user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
self.book = models.Edition.objects.create(title='Example Edition')
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_quotation(self):
|
||||||
|
quotation = status_builder.create_quotation(
|
||||||
|
self.user, self.book, 'commentary', 'a quote')
|
||||||
|
self.assertEqual(quotation.quote, 'a quote')
|
||||||
|
self.assertEqual(quotation.content, 'commentary')
|
||||||
|
|
||||||
|
|
||||||
|
def test_quotation_from_activity(self):
|
||||||
|
activity = {
|
||||||
|
'id': 'https://example.com/user/mouse/quotation/13',
|
||||||
|
'url': 'https://example.com/user/mouse/quotation/13',
|
||||||
|
'inReplyTo': None,
|
||||||
|
'published': '2020-05-10T02:38:31.150343+00:00',
|
||||||
|
'attributedTo': 'https://example.com/user/mouse',
|
||||||
|
'to': [
|
||||||
|
'https://www.w3.org/ns/activitystreams#Public'
|
||||||
|
],
|
||||||
|
'cc': [
|
||||||
|
'https://example.com/user/mouse/followers'
|
||||||
|
],
|
||||||
|
'sensitive': False,
|
||||||
|
'content': 'commentary',
|
||||||
|
'type': 'Note',
|
||||||
|
'attachment': [
|
||||||
|
{
|
||||||
|
'type': 'Document',
|
||||||
|
'mediaType': 'image//images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg',
|
||||||
|
'url': 'https://example.com/images/covers/2b4e4712-5a4d-4ac1-9df4-634cc9c7aff3jpg',
|
||||||
|
'name': 'Cover of \'This Is How You Lose the Time War\''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'replies': {
|
||||||
|
'id': 'https://example.com/user/mouse/quotation/13/replies',
|
||||||
|
'type': 'Collection',
|
||||||
|
'first': {
|
||||||
|
'type': 'CollectionPage',
|
||||||
|
'next': 'https://example.com/user/mouse/quotation/13/replies?only_other_accounts=true&page=true',
|
||||||
|
'partOf': 'https://example.com/user/mouse/quotation/13/replies',
|
||||||
|
'items': []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'inReplyToBook': self.book.absolute_id,
|
||||||
|
'fedireadsType': 'Quotation',
|
||||||
|
'quote': 'quote body'
|
||||||
|
}
|
||||||
|
quotation = status_builder.create_quotation_from_activity(
|
||||||
|
self.user, activity)
|
||||||
|
self.assertEqual(quotation.content, 'commentary')
|
||||||
|
self.assertEqual(quotation.quote, 'quote body')
|
||||||
|
self.assertEqual(quotation.book, self.book)
|
||||||
|
self.assertEqual(
|
||||||
|
quotation.published_date, '2020-05-10T02:38:31.150343+00:00')
|
81
fedireads/tests/test_review.py
Normal file
81
fedireads/tests/test_review.py
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
from fedireads import status as status_builder
|
||||||
|
|
||||||
|
|
||||||
|
class Review(TestCase):
|
||||||
|
''' we have hecka ways to create statuses '''
|
||||||
|
def setUp(self):
|
||||||
|
self.user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
self.book = models.Edition.objects.create(title='Example Edition')
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_review(self):
|
||||||
|
review = status_builder.create_review(
|
||||||
|
self.user, self.book, 'review name', 'content', 5)
|
||||||
|
self.assertEqual(review.name, 'review name')
|
||||||
|
self.assertEqual(review.content, 'content')
|
||||||
|
self.assertEqual(review.rating, 5)
|
||||||
|
|
||||||
|
review = status_builder.create_review(
|
||||||
|
self.user, self.book, '<div>review</div> name', '<b>content', 5)
|
||||||
|
self.assertEqual(review.name, 'review name')
|
||||||
|
self.assertEqual(review.content, 'content')
|
||||||
|
self.assertEqual(review.rating, 5)
|
||||||
|
|
||||||
|
def test_review_rating(self):
|
||||||
|
review = status_builder.create_review(
|
||||||
|
self.user, self.book, 'review name', 'content', -1)
|
||||||
|
self.assertEqual(review.name, 'review name')
|
||||||
|
self.assertEqual(review.content, 'content')
|
||||||
|
self.assertEqual(review.rating, None)
|
||||||
|
|
||||||
|
review = status_builder.create_review(
|
||||||
|
self.user, self.book, 'review name', 'content', 6)
|
||||||
|
self.assertEqual(review.name, 'review name')
|
||||||
|
self.assertEqual(review.content, 'content')
|
||||||
|
self.assertEqual(review.rating, None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_from_activity(self):
|
||||||
|
activity = {
|
||||||
|
'id': 'https://example.com/user/mouse/review/9',
|
||||||
|
'url': 'https://example.com/user/mouse/review/9',
|
||||||
|
'inReplyTo': None,
|
||||||
|
'published': '2020-05-04T00:00:00.000000+00:00',
|
||||||
|
'attributedTo': 'https://example.com/user/mouse',
|
||||||
|
'to': [
|
||||||
|
'https://www.w3.org/ns/activitystreams#Public'
|
||||||
|
],
|
||||||
|
'cc': [
|
||||||
|
'https://example.com/user/mouse/followers'
|
||||||
|
],
|
||||||
|
'sensitive': False,
|
||||||
|
'content': 'review content',
|
||||||
|
'type': 'Article',
|
||||||
|
'attachment': [],
|
||||||
|
'replies': {
|
||||||
|
'id': 'https://example.com/user/mouse/review/9/replies',
|
||||||
|
'type': 'Collection',
|
||||||
|
'first': {
|
||||||
|
'type': 'CollectionPage',
|
||||||
|
'next': 'https://example.com/user/mouse/review/9/replies?only_other_accounts=true&page=true',
|
||||||
|
'partOf': 'https://example.com/user/mouse/review/9/replies',
|
||||||
|
'items': []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'inReplyToBook': self.book.absolute_id,
|
||||||
|
'fedireadsType': 'Review',
|
||||||
|
'name': 'review title',
|
||||||
|
'rating': 3
|
||||||
|
}
|
||||||
|
review = status_builder.create_review_from_activity(
|
||||||
|
self.user, activity)
|
||||||
|
self.assertEqual(review.content, 'review content')
|
||||||
|
self.assertEqual(review.name, 'review title')
|
||||||
|
self.assertEqual(review.rating, 3)
|
||||||
|
self.assertEqual(review.book, self.book)
|
||||||
|
self.assertEqual(
|
||||||
|
review.published_date, '2020-05-04T00:00:00.000000+00:00')
|
50
fedireads/tests/test_sanitize_html.py
Normal file
50
fedireads/tests/test_sanitize_html.py
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads.sanitize_html import InputHtmlParser
|
||||||
|
|
||||||
|
|
||||||
|
class Sanitizer(TestCase):
|
||||||
|
def test_no_html(self):
|
||||||
|
input_text = 'no html '
|
||||||
|
parser = InputHtmlParser()
|
||||||
|
parser.feed(input_text)
|
||||||
|
output = parser.get_output()
|
||||||
|
self.assertEqual(input_text, output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_html(self):
|
||||||
|
input_text = '<b>yes </b> <i>html</i>'
|
||||||
|
parser = InputHtmlParser()
|
||||||
|
parser.feed(input_text)
|
||||||
|
output = parser.get_output()
|
||||||
|
self.assertEqual(input_text, output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_valid_html_attrs(self):
|
||||||
|
input_text = '<a href="fish.com">yes </a> <i>html</i>'
|
||||||
|
parser = InputHtmlParser()
|
||||||
|
parser.feed(input_text)
|
||||||
|
output = parser.get_output()
|
||||||
|
self.assertEqual(input_text, output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_html(self):
|
||||||
|
input_text = '<b>yes <i>html</i>'
|
||||||
|
parser = InputHtmlParser()
|
||||||
|
parser.feed(input_text)
|
||||||
|
output = parser.get_output()
|
||||||
|
self.assertEqual('yes html', output)
|
||||||
|
|
||||||
|
input_text = 'yes <i></b>html </i>'
|
||||||
|
parser = InputHtmlParser()
|
||||||
|
parser.feed(input_text)
|
||||||
|
output = parser.get_output()
|
||||||
|
self.assertEqual('yes html ', output)
|
||||||
|
|
||||||
|
|
||||||
|
def test_disallowed_html(self):
|
||||||
|
input_text = '<div> yes <i>html</i></div>'
|
||||||
|
parser = InputHtmlParser()
|
||||||
|
parser.feed(input_text)
|
||||||
|
output = parser.get_output()
|
||||||
|
self.assertEqual(' yes <i>html</i>', output)
|
64
fedireads/tests/test_status.py
Normal file
64
fedireads/tests/test_status.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
from fedireads import status as status_builder
|
||||||
|
|
||||||
|
|
||||||
|
class Status(TestCase):
|
||||||
|
''' we have hecka ways to create statuses '''
|
||||||
|
def setUp(self):
|
||||||
|
self.user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_status(self):
|
||||||
|
content = 'statuses are usually <i>replies</i>'
|
||||||
|
status = status_builder.create_status(
|
||||||
|
self.user, content)
|
||||||
|
self.assertEqual(status.content, content)
|
||||||
|
|
||||||
|
reply = status_builder.create_status(
|
||||||
|
self.user, content, reply_parent=status)
|
||||||
|
self.assertEqual(reply.content, content)
|
||||||
|
self.assertEqual(reply.reply_parent, status)
|
||||||
|
|
||||||
|
def test_create_status_from_activity(self):
|
||||||
|
book = models.Edition.objects.create(title='Example Edition')
|
||||||
|
review = status_builder.create_review(
|
||||||
|
self.user, book, 'review name', 'content', 5)
|
||||||
|
activity = {
|
||||||
|
'id': 'https://example.com/user/mouse/status/12',
|
||||||
|
'url': 'https://example.com/user/mouse/status/12',
|
||||||
|
'inReplyTo': review.absolute_id,
|
||||||
|
'published': '2020-05-10T02:15:59.635557+00:00',
|
||||||
|
'attributedTo': 'https://example.com/user/mouse',
|
||||||
|
'to': [
|
||||||
|
'https://www.w3.org/ns/activitystreams#Public'
|
||||||
|
],
|
||||||
|
'cc': [
|
||||||
|
'https://example.com/user/mouse/followers'
|
||||||
|
],
|
||||||
|
'sensitive': False,
|
||||||
|
'content': 'reply to status',
|
||||||
|
'type': 'Note',
|
||||||
|
'attachment': [],
|
||||||
|
'replies': {
|
||||||
|
'id': 'https://example.com/user/mouse/status/12/replies',
|
||||||
|
'type': 'Collection',
|
||||||
|
'first': {
|
||||||
|
'type': 'CollectionPage',
|
||||||
|
'next': 'https://example.com/user/mouse/status/12/replies?only_other_accounts=true&page=true',
|
||||||
|
'partOf': 'https://example.com/user/mouse/status/12/replies',
|
||||||
|
'items': []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status = status_builder.create_status_from_activity(
|
||||||
|
self.user, activity)
|
||||||
|
self.assertEqual(status.reply_parent, review)
|
||||||
|
self.assertEqual(status.content, 'reply to status')
|
||||||
|
self.assertEqual(
|
||||||
|
status.published_date,
|
||||||
|
'2020-05-10T02:15:59.635557+00:00'
|
||||||
|
)
|
60
fedireads/tests/test_status_model.py
Normal file
60
fedireads/tests/test_status_model.py
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
''' testing models '''
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models, settings
|
||||||
|
|
||||||
|
|
||||||
|
class Status(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
book = models.Edition.objects.create(title='Example Edition')
|
||||||
|
|
||||||
|
models.Status.objects.create(user=user, content='Blah blah')
|
||||||
|
models.Comment.objects.create(user=user, content='content', book=book)
|
||||||
|
models.Quotation.objects.create(
|
||||||
|
user=user, content='content', book=book, quote='blah')
|
||||||
|
models.Review.objects.create(
|
||||||
|
user=user, content='content', book=book, rating=3)
|
||||||
|
|
||||||
|
def test_status(self):
|
||||||
|
status = models.Status.objects.first()
|
||||||
|
self.assertEqual(status.status_type, 'Note')
|
||||||
|
self.assertEqual(status.activity_type, 'Note')
|
||||||
|
expected_id = 'https://%s/user/mouse/status/%d' % \
|
||||||
|
(settings.DOMAIN, status.id)
|
||||||
|
self.assertEqual(status.absolute_id, expected_id)
|
||||||
|
|
||||||
|
def test_comment(self):
|
||||||
|
comment = models.Comment.objects.first()
|
||||||
|
self.assertEqual(comment.status_type, 'Comment')
|
||||||
|
self.assertEqual(comment.activity_type, 'Note')
|
||||||
|
expected_id = 'https://%s/user/mouse/comment/%d' % \
|
||||||
|
(settings.DOMAIN, comment.id)
|
||||||
|
self.assertEqual(comment.absolute_id, expected_id)
|
||||||
|
|
||||||
|
def test_quotation(self):
|
||||||
|
quotation = models.Quotation.objects.first()
|
||||||
|
self.assertEqual(quotation.status_type, 'Quotation')
|
||||||
|
self.assertEqual(quotation.activity_type, 'Note')
|
||||||
|
expected_id = 'https://%s/user/mouse/quotation/%d' % \
|
||||||
|
(settings.DOMAIN, quotation.id)
|
||||||
|
self.assertEqual(quotation.absolute_id, expected_id)
|
||||||
|
|
||||||
|
def test_review(self):
|
||||||
|
review = models.Review.objects.first()
|
||||||
|
self.assertEqual(review.status_type, 'Review')
|
||||||
|
self.assertEqual(review.activity_type, 'Article')
|
||||||
|
expected_id = 'https://%s/user/mouse/review/%d' % \
|
||||||
|
(settings.DOMAIN, review.id)
|
||||||
|
self.assertEqual(review.absolute_id, expected_id)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(TestCase):
|
||||||
|
def test_tag(self):
|
||||||
|
book = models.Edition.objects.create(title='Example Edition')
|
||||||
|
user = models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
tag = models.Tag.objects.create(user=user, book=book, name='t/est tag')
|
||||||
|
self.assertEqual(tag.identifier, 't%2Fest+tag')
|
||||||
|
|
35
fedireads/tests/test_user_model.py
Normal file
35
fedireads/tests/test_user_model.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
''' testing models '''
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from fedireads import models
|
||||||
|
from fedireads.settings import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
class User(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
models.User.objects.create_user(
|
||||||
|
'mouse', 'mouse@mouse.mouse', 'mouseword')
|
||||||
|
|
||||||
|
def test_computed_fields(self):
|
||||||
|
''' username instead of id here '''
|
||||||
|
user = models.User.objects.get(localname='mouse')
|
||||||
|
expected_id = 'https://%s/user/mouse' % DOMAIN
|
||||||
|
self.assertEqual(user.absolute_id, expected_id)
|
||||||
|
self.assertEqual(user.username, 'mouse@%s' % DOMAIN)
|
||||||
|
self.assertEqual(user.localname, 'mouse')
|
||||||
|
self.assertEqual(user.actor, 'https://%s/user/mouse' % DOMAIN)
|
||||||
|
self.assertEqual(user.shared_inbox, 'https://%s/inbox' % DOMAIN)
|
||||||
|
self.assertEqual(user.inbox, '%s/inbox' % expected_id)
|
||||||
|
self.assertEqual(user.outbox, '%s/outbox' % expected_id)
|
||||||
|
self.assertIsNotNone(user.private_key)
|
||||||
|
self.assertIsNotNone(user.public_key)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_shelves(self):
|
||||||
|
user = models.User.objects.get(localname='mouse')
|
||||||
|
shelves = models.Shelf.objects.filter(user=user).all()
|
||||||
|
self.assertEqual(len(shelves), 3)
|
||||||
|
names = [s.name for s in shelves]
|
||||||
|
self.assertEqual(names, ['To Read', 'Currently Reading', 'Read'])
|
||||||
|
ids = [s.identifier for s in shelves]
|
||||||
|
self.assertEqual(ids, ['to-read', 'reading', 'read'])
|
|
@ -10,7 +10,7 @@ username_regex = r'(?P<username>[\w\-_]+@[\w\-\_\.]+)'
|
||||||
localname_regex = r'(?P<username>[\w\-_]+)'
|
localname_regex = r'(?P<username>[\w\-_]+)'
|
||||||
user_path = r'^user/%s' % username_regex
|
user_path = r'^user/%s' % username_regex
|
||||||
local_user_path = r'^user/%s' % localname_regex
|
local_user_path = r'^user/%s' % localname_regex
|
||||||
status_path = r'%s/(status|review|comment)/(?P<status_id>\d+)' % local_user_path
|
status_path = r'%s/(status|review|comment|quotation)/(?P<status_id>\d+)' % local_user_path
|
||||||
book_path = r'^book/(?P<book_id>\d+)'
|
book_path = r'^book/(?P<book_id>\d+)'
|
||||||
|
|
||||||
handler404 = 'fedireads.views.not_found_page'
|
handler404 = 'fedireads.views.not_found_page'
|
||||||
|
@ -65,8 +65,8 @@ urlpatterns = [
|
||||||
|
|
||||||
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
|
re_path(r'^author/(?P<author_id>[\w\-]+)(.json)?/?$', views.author_page),
|
||||||
re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page),
|
re_path(r'^tag/(?P<tag_id>.+)/?$', views.tag_page),
|
||||||
re_path(r'^shelf/%s/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % username_regex, views.shelf_page),
|
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % user_path, views.shelf_page),
|
||||||
re_path(r'^shelf/%s/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % localname_regex, views.shelf_page),
|
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % local_user_path, views.shelf_page),
|
||||||
|
|
||||||
re_path(r'^search/?$', views.search),
|
re_path(r'^search/?$', views.search),
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ from django.db.models import Avg, Q
|
||||||
from django.http import HttpResponseBadRequest, HttpResponseNotFound,\
|
from django.http import HttpResponseBadRequest, HttpResponseNotFound,\
|
||||||
JsonResponse
|
JsonResponse
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.shortcuts import redirect
|
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
||||||
|
@ -239,7 +238,7 @@ def user_page(request, username, subpage=None):
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
# we have a json request
|
# we have a json request
|
||||||
return JsonResponse(activitypub.get_actor(user))
|
return JsonResponse(user.activitypub_serialize)
|
||||||
# otherwise we're at a UI view
|
# otherwise we're at a UI view
|
||||||
|
|
||||||
# TODO: change display with privacy and authentication considerations
|
# TODO: change display with privacy and authentication considerations
|
||||||
|
@ -330,7 +329,7 @@ def status_page(request, username, status_id):
|
||||||
return HttpResponseNotFound()
|
return HttpResponseNotFound()
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
return JsonResponse(activitypub.get_status(status))
|
return JsonResponse(status.activitypub_serialize)
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'status': status,
|
'status': status,
|
||||||
|
@ -390,7 +389,7 @@ def edit_profile_page(request):
|
||||||
|
|
||||||
def book_page(request, book_id, tab='friends'):
|
def book_page(request, book_id, tab='friends'):
|
||||||
''' info about a book '''
|
''' info about a book '''
|
||||||
book = get_or_create_book(book_id)
|
book = models.Book.objects.select_subclasses().get(id=book_id)
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
return JsonResponse(activitypub.get_book(book))
|
return JsonResponse(activitypub.get_book(book))
|
||||||
|
|
||||||
|
@ -531,7 +530,8 @@ def shelf_page(request, username, shelf_identifier):
|
||||||
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier)
|
shelf = models.Shelf.objects.get(user=user, identifier=shelf_identifier)
|
||||||
|
|
||||||
if is_api_request(request):
|
if is_api_request(request):
|
||||||
return activitypub.get_shelf(shelf)
|
page = request.GET.get('page')
|
||||||
|
return JsonResponse(activitypub.get_shelf(shelf, page=page))
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'shelf': shelf,
|
'shelf': shelf,
|
||||||
|
|
|
@ -53,23 +53,23 @@ def nodeinfo(request):
|
||||||
status_count = models.Status.objects.filter(user__local=True).count()
|
status_count = models.Status.objects.filter(user__local=True).count()
|
||||||
user_count = models.User.objects.count()
|
user_count = models.User.objects.count()
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"version": "2.0",
|
'version': '2.0',
|
||||||
"software": {
|
'software': {
|
||||||
"name": "fedireads",
|
'name': 'fedireads',
|
||||||
"version": "0.0.1"
|
'version': '0.0.1'
|
||||||
},
|
},
|
||||||
"protocols": [
|
'protocols': [
|
||||||
"activitypub"
|
'activitypub'
|
||||||
],
|
],
|
||||||
"usage": {
|
'usage': {
|
||||||
"users": {
|
'users': {
|
||||||
"total": user_count,
|
'total': user_count,
|
||||||
"activeMonth": user_count, # TODO
|
'activeMonth': user_count, # TODO
|
||||||
"activeHalfyear": user_count, # TODO
|
'activeHalfyear': user_count, # TODO
|
||||||
},
|
},
|
||||||
"localPosts": status_count,
|
'localPosts': status_count,
|
||||||
},
|
},
|
||||||
"openRegistrations": True,
|
'openRegistrations': True,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue