forked from mirrors/bookwyrm
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 '''
|
||||
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 .follow import get_following, get_followers
|
||||
from .follow import get_follow_request, get_unfollow, get_accept, get_reject
|
||||
|
|
|
@ -86,3 +86,42 @@ def get_author(author):
|
|||
if hasattr(author, field):
|
||||
activity[field] = author.__getattribute__(field)
|
||||
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'):
|
||||
''' format an Add or Remove json blob '''
|
||||
''' format a shelve book json blob '''
|
||||
uuid = uuid4()
|
||||
return {
|
||||
'@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
|
||||
followers = followers.filter(fedireads_user=(software == 'fedireads'))
|
||||
|
||||
# we want shared inboxes when available
|
||||
shared = followers.filter(
|
||||
shared_inbox__isnull=False
|
||||
).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(
|
||||
shared_inbox__isnull=True
|
||||
).values_list('inbox', flat=True)
|
||||
|
@ -33,7 +36,9 @@ def get_public_recipients(user, software=None):
|
|||
def broadcast(sender, activity, software=None, \
|
||||
privacy='public', direct_recipients=None):
|
||||
''' send out an event '''
|
||||
# start with parsing the direct recipients
|
||||
recipients = [u.inbox for u in direct_recipients or []]
|
||||
# and then add any other recipients
|
||||
# TODO: other kinds of privacy
|
||||
if privacy == 'public':
|
||||
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)
|
||||
|
||||
# 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))
|
||||
signed_message = signer.sign(SHA256.new(message_to_sign.encode('utf8')))
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ class AbstractConnector(ABC):
|
|||
for field in fields:
|
||||
setattr(self, field, getattr(info, field))
|
||||
|
||||
|
||||
def is_available(self):
|
||||
''' check if you're allowed to use this connector '''
|
||||
if self.max_query_count is not None:
|
||||
|
@ -266,25 +267,25 @@ def update_from_mappings(obj, data, mappings):
|
|||
if key == 'id':
|
||||
continue
|
||||
|
||||
if has_attr(obj, key):
|
||||
try:
|
||||
hasattr(obj, key)
|
||||
except ValueError:
|
||||
obj.__setattr__(key, formatter(value))
|
||||
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):
|
||||
''' helper function to try to interpret dates '''
|
||||
if not date_string:
|
||||
return None
|
||||
|
||||
try:
|
||||
return pytz.utc.localize(parser.parse(date_string))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
return parser.parse(date_string)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ class Connector(AbstractConnector):
|
|||
|
||||
|
||||
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):
|
||||
|
@ -86,14 +86,13 @@ class Connector(AbstractConnector):
|
|||
|
||||
|
||||
def format_search_result(self, doc):
|
||||
key = doc['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']
|
||||
return SearchResult(
|
||||
doc.get('title'),
|
||||
key,
|
||||
author[0],
|
||||
', '.join(author),
|
||||
doc.get('first_publish_year'),
|
||||
)
|
||||
|
||||
|
|
|
@ -101,7 +101,7 @@ class EditionForm(ModelForm):
|
|||
'updated_date',
|
||||
'last_sync_date',
|
||||
|
||||
'authors',
|
||||
'authors',# TODO
|
||||
'parent_work',
|
||||
'shelves',
|
||||
'misc_identifiers',
|
||||
|
|
|
@ -12,6 +12,7 @@ MAX_ENTRIES = 500
|
|||
|
||||
|
||||
def create_job(user, csv_file):
|
||||
''' check over a csv and creates a database entry for the job'''
|
||||
job = ImportJob.objects.create(user=user)
|
||||
for index, entry in enumerate(list(csv.DictReader(csv_file))[:MAX_ENTRIES]):
|
||||
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()
|
||||
return job
|
||||
|
||||
|
||||
def start_import(job):
|
||||
''' initalizes a csv import job '''
|
||||
result = import_data.delay(job.id)
|
||||
job.task_id = result.id
|
||||
job.save()
|
||||
|
||||
|
||||
@app.task
|
||||
def import_data(job_id):
|
||||
''' does the actual lookup work in a celery task '''
|
||||
job = ImportJob.objects.get(id=job_id)
|
||||
try:
|
||||
results = []
|
||||
|
|
|
@ -42,6 +42,9 @@ def shared_inbox(request):
|
|||
except json.decoder.JSONDecodeError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not activity.get('object'):
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
try:
|
||||
verify_signature(request)
|
||||
except ValueError:
|
||||
|
@ -128,9 +131,13 @@ def verify_signature(request):
|
|||
@app.task
|
||||
def handle_follow(activity):
|
||||
''' someone wants to follow a local user '''
|
||||
# figure out who they want to follow
|
||||
to_follow = models.User.objects.get(actor=activity['object'])
|
||||
# figure out who they are
|
||||
# figure out who they want to follow -- not using get_or_create because
|
||||
# we only allow you to follow local users
|
||||
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'])
|
||||
try:
|
||||
request = models.UserFollowRequest.objects.create(
|
||||
|
@ -165,14 +172,11 @@ def handle_follow(activity):
|
|||
def handle_unfollow(activity):
|
||||
''' unfollow a local user '''
|
||||
obj = activity['object']
|
||||
if not obj['type'] == 'Follow':
|
||||
#idk how to undo other things
|
||||
return HttpResponseNotFound()
|
||||
try:
|
||||
requester = get_or_create_remote_user(obj['actor'])
|
||||
to_unfollow = models.User.objects.get(actor=obj['object'])
|
||||
except models.User.DoesNotExist:
|
||||
return HttpResponseNotFound()
|
||||
return False
|
||||
|
||||
to_unfollow.followers.remove(requester)
|
||||
|
||||
|
@ -209,7 +213,7 @@ def handle_follow_reject(activity):
|
|||
)
|
||||
request.delete()
|
||||
except models.UserFollowRequest.DoesNotExist:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@app.task
|
||||
|
@ -217,46 +221,37 @@ def handle_create(activity):
|
|||
''' someone did something, good on them '''
|
||||
user = get_or_create_remote_user(activity['actor'])
|
||||
|
||||
if not 'object' in activity:
|
||||
return False
|
||||
|
||||
if user.local:
|
||||
# we really oughtn't even be sending in this case
|
||||
return True
|
||||
|
||||
if activity['object'].get('fedireadsType') and \
|
||||
'inReplyToBook' in activity['object']:
|
||||
try:
|
||||
if activity['object']['fedireadsType'] == 'Review':
|
||||
builder = status_builder.create_review_from_activity
|
||||
elif activity['object']['fedireadsType'] == 'Quotation':
|
||||
builder = status_builder.create_quotation_from_activity
|
||||
else:
|
||||
builder = status_builder.create_comment_from_activity
|
||||
if activity['object']['fedireadsType'] == 'Review':
|
||||
builder = status_builder.create_review_from_activity
|
||||
elif activity['object']['fedireadsType'] == 'Quotation':
|
||||
builder = status_builder.create_quotation_from_activity
|
||||
else:
|
||||
builder = status_builder.create_comment_from_activity
|
||||
|
||||
# create the status, it'll throw a valueerror if anything is missing
|
||||
builder(user, activity['object'])
|
||||
except ValueError:
|
||||
return False
|
||||
# create the status, it'll throw a ValueError if anything is missing
|
||||
builder(user, activity['object'])
|
||||
elif activity['object'].get('inReplyTo'):
|
||||
# only create the status if it's in reply to a status we already know
|
||||
if not status_builder.get_status(activity['object']['inReplyTo']):
|
||||
return True
|
||||
|
||||
try:
|
||||
status = status_builder.create_status_from_activity(
|
||||
user,
|
||||
activity['object']
|
||||
status = status_builder.create_status_from_activity(
|
||||
user,
|
||||
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
|
||||
|
||||
|
||||
|
@ -268,7 +263,7 @@ def handle_favorite(activity):
|
|||
status = models.Status.objects.get(id=status_id)
|
||||
liker = get_or_create_remote_user(activity['actor'])
|
||||
except (models.Status.DoesNotExist, models.User.DoesNotExist):
|
||||
return
|
||||
return False
|
||||
|
||||
if not liker.local:
|
||||
status_builder.create_favorite_from_activity(liker, activity)
|
||||
|
@ -287,7 +282,7 @@ def handle_unfavorite(activity):
|
|||
favorite_id = activity['object']['id']
|
||||
fav = status_builder.get_favorite(favorite_id)
|
||||
if not fav:
|
||||
return HttpResponseNotFound()
|
||||
return False
|
||||
|
||||
fav.delete()
|
||||
|
||||
|
@ -300,7 +295,7 @@ def handle_boost(activity):
|
|||
status = models.Status.objects.get(id=status_id)
|
||||
booster = get_or_create_remote_user(activity['actor'])
|
||||
except (models.Status.DoesNotExist, models.User.DoesNotExist):
|
||||
return HttpResponseNotFound()
|
||||
return False
|
||||
|
||||
if not booster.local:
|
||||
status_builder.create_boost_from_activity(booster, activity)
|
||||
|
@ -315,10 +310,10 @@ def handle_boost(activity):
|
|||
|
||||
@app.task
|
||||
def handle_tag(activity):
|
||||
''' someone is tagging or shelving a book '''
|
||||
''' someone is tagging a book '''
|
||||
user = get_or_create_remote_user(activity['actor'])
|
||||
if not user.local:
|
||||
book = activity['target']['id'].split('/')[-1]
|
||||
book = activity['target']['id']
|
||||
status_builder.create_tag(user, book, activity['object']['name'])
|
||||
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ from django.db import models
|
|||
|
||||
from fedireads.settings import DOMAIN
|
||||
|
||||
# TODO maybe this should be in /models?
|
||||
class FedireadsModel(models.Model):
|
||||
''' fields and functions for every model '''
|
||||
created_date = models.DateTimeField(auto_now_add=True)
|
||||
|
@ -12,6 +11,9 @@ class FedireadsModel(models.Model):
|
|||
@property
|
||||
def absolute_id(self):
|
||||
''' constructs the absolute reference to any db object '''
|
||||
if self.remote_id:
|
||||
return self.remote_id
|
||||
|
||||
base_path = 'https://%s' % DOMAIN
|
||||
if hasattr(self, 'user'):
|
||||
base_path = self.user.absolute_id
|
|
@ -3,9 +3,10 @@ from django.utils import timezone
|
|||
from django.db import models
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from fedireads import activitypub
|
||||
from fedireads.settings import DOMAIN
|
||||
from fedireads.utils.fields import JSONField, ArrayField
|
||||
from fedireads.utils.models import FedireadsModel
|
||||
from .base_model import FedireadsModel
|
||||
|
||||
from fedireads.connectors.settings import CONNECTORS
|
||||
|
||||
|
@ -110,6 +111,10 @@ class Book(FedireadsModel):
|
|||
self.title,
|
||||
)
|
||||
|
||||
@property
|
||||
def activitypub_serialize(self):
|
||||
return activitypub.get_book(self)
|
||||
|
||||
|
||||
class Work(Book):
|
||||
''' 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
|
||||
)
|
||||
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 dateutil.parser
|
||||
|
||||
|
@ -5,7 +6,7 @@ from django.db import models
|
|||
from django.utils import timezone
|
||||
|
||||
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
|
||||
|
||||
# Mapping goodreads -> fedireads shelf titles.
|
||||
|
@ -32,6 +33,7 @@ def construct_search_term(title, author):
|
|||
|
||||
return ' '.join([title, author])
|
||||
|
||||
|
||||
class ImportJob(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
created_date = models.DateTimeField(default=timezone.now)
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
''' puttin' books on shelves '''
|
||||
from django.db import models
|
||||
|
||||
from fedireads.utils.models import FedireadsModel
|
||||
from fedireads import activitypub
|
||||
from .base_model import FedireadsModel
|
||||
|
||||
|
||||
class Shelf(FedireadsModel):
|
||||
|
|
|
@ -6,7 +6,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
|||
from django.db import models
|
||||
from model_utils.managers import InheritanceManager
|
||||
|
||||
from fedireads.utils.models import FedireadsModel
|
||||
from fedireads import activitypub
|
||||
from .base_model import FedireadsModel
|
||||
|
||||
|
||||
class Status(FedireadsModel):
|
||||
|
@ -48,6 +49,11 @@ class Status(FedireadsModel):
|
|||
return '%s/%s/%d' % (base_path, model_name, self.id)
|
||||
|
||||
|
||||
@property
|
||||
def activitypub_serialize(self):
|
||||
return activitypub.get_status(self)
|
||||
|
||||
|
||||
class Comment(Status):
|
||||
''' like a review but without a rating and transient '''
|
||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||
|
@ -58,6 +64,11 @@ class Comment(Status):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@property
|
||||
def activitypub_serialize(self):
|
||||
return activitypub.get_comment(self)
|
||||
|
||||
|
||||
class Quotation(Status):
|
||||
''' like a review but without a rating and transient '''
|
||||
book = models.ForeignKey('Edition', on_delete=models.PROTECT)
|
||||
|
@ -69,6 +80,11 @@ class Quotation(Status):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@property
|
||||
def activitypub_serialize(self):
|
||||
return activitypub.get_quotation(self)
|
||||
|
||||
|
||||
class Review(Status):
|
||||
''' a book review '''
|
||||
name = models.CharField(max_length=255, null=True)
|
||||
|
@ -86,19 +102,17 @@ class Review(Status):
|
|||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
@property
|
||||
def activitypub_serialize(self):
|
||||
return activitypub.get_review(self)
|
||||
|
||||
|
||||
class Favorite(FedireadsModel):
|
||||
''' fav'ing a post '''
|
||||
user = models.ForeignKey('User', on_delete=models.PROTECT)
|
||||
status = models.ForeignKey('Status', on_delete=models.PROTECT)
|
||||
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:
|
||||
unique_together = ('user', 'status')
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ from django.dispatch import receiver
|
|||
|
||||
from fedireads.models.shelf import Shelf
|
||||
from fedireads.settings import DOMAIN
|
||||
from fedireads.utils.models import FedireadsModel
|
||||
from .base_model import FedireadsModel
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
|
@ -73,6 +73,10 @@ class User(AbstractUser):
|
|||
username = self.localname or self.username
|
||||
return 'https://%s/%s/%s' % (DOMAIN, model_name, username)
|
||||
|
||||
@property
|
||||
def activitypub_serialize(self):
|
||||
return activitypub.get_actor(self)
|
||||
|
||||
|
||||
class UserRelationship(FedireadsModel):
|
||||
''' many-to-many through table for followers '''
|
||||
|
|
|
@ -19,19 +19,19 @@ class InputHtmlParser(HTMLParser):
|
|||
self.output.append(('tag', self.get_starttag_text()))
|
||||
self.tag_stack.append(tag)
|
||||
else:
|
||||
self.output.append(('data', ' '))
|
||||
self.output.append(('data', ''))
|
||||
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
''' keep the close tag '''
|
||||
if not self.allow_html or tag not in self.whitelist:
|
||||
self.output.append(('data', ' '))
|
||||
self.output.append(('data', ''))
|
||||
return
|
||||
|
||||
if not self.tag_stack or self.tag_stack[-1] != tag:
|
||||
# the end tag doesn't match the most recent start tag
|
||||
self.allow_html = False
|
||||
self.output.append(('data', ' '))
|
||||
self.output.append(('data', ''))
|
||||
return
|
||||
|
||||
self.tag_stack = self.tag_stack[:-1]
|
||||
|
@ -45,6 +45,8 @@ class InputHtmlParser(HTMLParser):
|
|||
|
||||
def get_output(self):
|
||||
''' convert the output from a list of tuples to a string '''
|
||||
if self.tag_stack:
|
||||
self.allow_html = False
|
||||
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)
|
||||
|
|
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\-_]+)'
|
||||
user_path = r'^user/%s' % username_regex
|
||||
local_user_path = r'^user/%s' % localname_regex
|
||||
status_path = r'%s/(status|review|comment)/(?P<status_id>\d+)' % local_user_path
|
||||
status_path = r'%s/(status|review|comment|quotation)/(?P<status_id>\d+)' % local_user_path
|
||||
book_path = r'^book/(?P<book_id>\d+)'
|
||||
|
||||
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'^tag/(?P<tag_id>.+)/?$', views.tag_page),
|
||||
re_path(r'^shelf/%s/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % username_regex, views.shelf_page),
|
||||
re_path(r'^shelf/%s/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % localname_regex, views.shelf_page),
|
||||
re_path(r'^%s/shelf/(?P<shelf_identifier>[\w-]+)(.json)?/?$' % user_path, 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),
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ from django.db.models import Avg, Q
|
|||
from django.http import HttpResponseBadRequest, HttpResponseNotFound,\
|
||||
JsonResponse
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
|
@ -239,7 +238,7 @@ def user_page(request, username, subpage=None):
|
|||
|
||||
if is_api_request(request):
|
||||
# we have a json request
|
||||
return JsonResponse(activitypub.get_actor(user))
|
||||
return JsonResponse(user.activitypub_serialize)
|
||||
# otherwise we're at a UI view
|
||||
|
||||
# TODO: change display with privacy and authentication considerations
|
||||
|
@ -330,7 +329,7 @@ def status_page(request, username, status_id):
|
|||
return HttpResponseNotFound()
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse(activitypub.get_status(status))
|
||||
return JsonResponse(status.activitypub_serialize)
|
||||
|
||||
data = {
|
||||
'status': status,
|
||||
|
@ -390,7 +389,7 @@ def edit_profile_page(request):
|
|||
|
||||
def book_page(request, book_id, tab='friends'):
|
||||
''' 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):
|
||||
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)
|
||||
|
||||
if is_api_request(request):
|
||||
return activitypub.get_shelf(shelf)
|
||||
page = request.GET.get('page')
|
||||
return JsonResponse(activitypub.get_shelf(shelf, page=page))
|
||||
|
||||
data = {
|
||||
'shelf': shelf,
|
||||
|
|
|
@ -53,23 +53,23 @@ def nodeinfo(request):
|
|||
status_count = models.Status.objects.filter(user__local=True).count()
|
||||
user_count = models.User.objects.count()
|
||||
return JsonResponse({
|
||||
"version": "2.0",
|
||||
"software": {
|
||||
"name": "fedireads",
|
||||
"version": "0.0.1"
|
||||
'version': '2.0',
|
||||
'software': {
|
||||
'name': 'fedireads',
|
||||
'version': '0.0.1'
|
||||
},
|
||||
"protocols": [
|
||||
"activitypub"
|
||||
'protocols': [
|
||||
'activitypub'
|
||||
],
|
||||
"usage": {
|
||||
"users": {
|
||||
"total": user_count,
|
||||
"activeMonth": user_count, # TODO
|
||||
"activeHalfyear": user_count, # TODO
|
||||
'usage': {
|
||||
'users': {
|
||||
'total': user_count,
|
||||
'activeMonth': user_count, # TODO
|
||||
'activeHalfyear': user_count, # TODO
|
||||
},
|
||||
"localPosts": status_count,
|
||||
'localPosts': status_count,
|
||||
},
|
||||
"openRegistrations": True,
|
||||
'openRegistrations': True,
|
||||
})
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue